feat(deploy): add vps deployment assets
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

This commit is contained in:
2026-03-10 18:30:45 +01:00
parent 81e97c4f3b
commit 9c26628561
9 changed files with 925 additions and 1 deletions

View File

@@ -29,6 +29,20 @@ make check-tenants
C'est tout ! L'application est prête.
### Deploiement de demo sur VPS
Pour une mise en ligne simple sur un petit serveur Ubuntu 24.04 (type OVH VPS-1), utilise le guide pas a pas :
- [docs/DEPLOYMENT_VPS1.md](docs/DEPLOYMENT_VPS1.md)
La configuration associee est deja prete dans :
- `compose.prod.yaml`
- `deploy/vps/.env.example`
- `deploy/vps/generate-env.sh`
- `deploy/vps/Caddyfile`
- `deploy/vps/generate-jwt.sh`
### Commandes utiles
```bash

159
compose.prod.yaml Normal file
View File

@@ -0,0 +1,159 @@
services:
caddy:
image: caddy:2.10-alpine
container_name: classeo_caddy
environment:
APP_DOMAIN: ${APP_DOMAIN}
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/vps/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- php
- frontend
restart: unless-stopped
php:
build:
context: ./backend
dockerfile: Dockerfile
target: prod
container_name: classeo_php
environment:
APP_ENV: prod
APP_SECRET: ${APP_SECRET}
TRUSTED_PROXIES: ${TRUSTED_PROXIES}
TRUSTED_HOSTS: ${TRUSTED_HOSTS}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${MASTER_DATABASE_NAME}?serverVersion=18&charset=utf8
REDIS_URL: redis://redis:6379
MESSENGER_TRANSPORT_DSN: doctrine://default?queue_name=async
MAILER_DSN: ${MAILER_DSN}
ADMIN_ALERT_EMAIL: ${ADMIN_ALERT_EMAIL}
DEFAULT_URI: https://${APP_DOMAIN}
TENANT_BASE_DOMAIN: ${PUBLIC_BASE_DOMAIN}
APP_URL: https://${APP_DOMAIN}
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN}
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY}
TURNSTILE_FAIL_OPEN: ${TURNSTILE_FAIL_OPEN}
JWT_SECRET_KEY: /app/config/jwt/private.pem
JWT_PUBLIC_KEY: /app/config/jwt/public.pem
JWT_PASSPHRASE: ${JWT_PASSPHRASE}
LOCK_DSN: redis://redis:6379
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT}
TENANT_CONFIGS: >-
[{"tenantId":"${TENANT_ID}","subdomain":"${TENANT_SUBDOMAIN}","databaseUrl":"postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${TENANT_DATABASE_NAME}?serverVersion=18&charset=utf8"}]
volumes:
- backend_uploads:/app/public/uploads
- ./backend/config/jwt:/app/config/jwt
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: prod
container_name: classeo_frontend
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 3000
ORIGIN: https://${APP_DOMAIN}
PUBLIC_BASE_DOMAIN: ${PUBLIC_BASE_DOMAIN}
PUBLIC_TURNSTILE_SITE_KEY: ${PUBLIC_TURNSTILE_SITE_KEY}
depends_on:
- php
restart: unless-stopped
worker:
build:
context: ./backend
dockerfile: Dockerfile
target: prod
container_name: classeo_worker
command:
- sh
- -lc
- until [ -f /app/config/jwt/private.pem ]; do sleep 1; done; php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M -vv
environment:
APP_ENV: prod
APP_SECRET: ${APP_SECRET}
TRUSTED_PROXIES: ${TRUSTED_PROXIES}
TRUSTED_HOSTS: ${TRUSTED_HOSTS}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${MASTER_DATABASE_NAME}?serverVersion=18&charset=utf8
REDIS_URL: redis://redis:6379
MESSENGER_TRANSPORT_DSN: doctrine://default?queue_name=async
MAILER_DSN: ${MAILER_DSN}
ADMIN_ALERT_EMAIL: ${ADMIN_ALERT_EMAIL}
DEFAULT_URI: https://${APP_DOMAIN}
TENANT_BASE_DOMAIN: ${PUBLIC_BASE_DOMAIN}
APP_URL: https://${APP_DOMAIN}
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN}
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY}
TURNSTILE_FAIL_OPEN: ${TURNSTILE_FAIL_OPEN}
JWT_SECRET_KEY: /app/config/jwt/private.pem
JWT_PUBLIC_KEY: /app/config/jwt/public.pem
JWT_PASSPHRASE: ${JWT_PASSPHRASE}
LOCK_DSN: redis://redis:6379
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT}
TENANT_CONFIGS: >-
[{"tenantId":"${TENANT_ID}","subdomain":"${TENANT_SUBDOMAIN}","databaseUrl":"postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${TENANT_DATABASE_NAME}?serverVersion=18&charset=utf8"}]
volumes:
- ./backend/config/jwt:/app/config/jwt
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
php:
condition: service_started
restart: unless-stopped
db:
image: postgres:18.1-alpine
container_name: classeo_db
environment:
POSTGRES_DB: ${MASTER_DATABASE_NAME}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TENANT_DATABASE_NAME: ${TENANT_DATABASE_NAME}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./deploy/vps/postgres/01-create-tenant-db.sh:/docker-entrypoint-initdb.d/01-create-tenant-db.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${MASTER_DATABASE_NAME}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
restart: unless-stopped
redis:
image: redis:7.4-alpine
container_name: classeo_redis
command: redis-server --save "" --appendonly no --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 5s
restart: unless-stopped
volumes:
backend_uploads:
caddy_config:
caddy_data:
postgres_data:
redis_data:

26
deploy/vps/.env.example Normal file
View File

@@ -0,0 +1,26 @@
APP_DOMAIN=demo.example.com
PUBLIC_BASE_DOMAIN=example.com
TENANT_ID=11111111-1111-1111-1111-111111111111
TENANT_SUBDOMAIN=demo
MASTER_DATABASE_NAME=classeo_master
TENANT_DATABASE_NAME=classeo_tenant_demo
POSTGRES_USER=classeo
POSTGRES_PASSWORD=change-this-db-password
APP_SECRET=change-this-app-secret
JWT_PASSPHRASE=change-this-jwt-passphrase
TRUSTED_PROXIES='127.0.0.1/32,172.16.0.0/12'
TRUSTED_HOSTS='^(.+\.)?example\.com$'
CORS_ALLOW_ORIGIN='^https://([\w-]+\.)?example\.com$'
MAILER_DSN=null://null
ADMIN_ALERT_EMAIL=admin@example.com
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
TURNSTILE_FAIL_OPEN=true
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
SENTRY_DSN=
SENTRY_ENVIRONMENT=production

15
deploy/vps/Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
{$APP_DOMAIN} {
encode zstd gzip
handle /api/* {
reverse_proxy php:8000
}
handle /uploads/* {
reverse_proxy php:8000
}
handle {
reverse_proxy frontend:3000
}
}

283
deploy/vps/generate-env.sh Executable file
View File

@@ -0,0 +1,283 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
ROOT_DIR=$(cd -- "${SCRIPT_DIR}/../.." && pwd)
EXAMPLE_FILE="${SCRIPT_DIR}/.env.example"
DEFAULT_TARGET_FILE="${SCRIPT_DIR}/.env"
TARGET_FILE="${1:-${DEFAULT_TARGET_FILE}}"
COMPOSE_FILE="${ROOT_DIR}/compose.prod.yaml"
if [ ! -f "${EXAMPLE_FILE}" ]; then
echo "Missing template: ${EXAMPLE_FILE}" >&2
exit 1
fi
if ! command -v openssl >/dev/null 2>&1; then
echo "openssl is required." >&2
exit 1
fi
command_exists() {
command -v "$1" >/dev/null 2>&1
}
load_existing_env() {
if [ -f "${TARGET_FILE}" ]; then
set -a
# shellcheck disable=SC1090
. "${TARGET_FILE}"
set +a
fi
}
generate_uuid() {
if command_exists uuidgen; then
uuidgen | tr '[:upper:]' '[:lower:]'
return
fi
if [ -r /proc/sys/kernel/random/uuid ]; then
tr '[:upper:]' '[:lower:]' < /proc/sys/kernel/random/uuid
return
fi
openssl rand -hex 16 | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/'
}
generate_secret() {
openssl rand -hex "${1}"
}
detect_public_ipv4() {
if ! command_exists curl; then
return 0
fi
for url in \
"https://api.ipify.org" \
"https://ifconfig.me/ip"
do
ip=$(curl -fsSL --max-time 3 "${url}" 2>/dev/null || true)
if printf '%s' "${ip}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'; then
printf '%s\n' "${ip}"
return 0
fi
done
}
prompt() {
local prompt_text="${1}"
local default_value="${2:-}"
local answer
if [ -n "${default_value}" ]; then
printf "%s [%s]: " "${prompt_text}" "${default_value}" >&2
else
printf "%s: " "${prompt_text}" >&2
fi
IFS= read -r answer
if [ -z "${answer}" ]; then
printf '%s\n' "${default_value}"
else
printf '%s\n' "${answer}"
fi
}
prompt_required() {
local prompt_text="${1}"
local default_value="${2:-}"
local value
while true; do
value=$(prompt "${prompt_text}" "${default_value}")
if [ -n "${value}" ]; then
printf '%s\n' "${value}"
return 0
fi
echo "This value is required." >&2
done
}
prompt_yes_no() {
local prompt_text="${1}"
local default_answer="${2:-y}"
local suffix="[Y/n]"
if [ "${default_answer}" = "n" ]; then
suffix="[y/N]"
fi
while true; do
printf "%s %s: " "${prompt_text}" "${suffix}"
IFS= read -r answer
answer=${answer:-${default_answer}}
case "${answer}" in
y|Y|yes|YES)
return 0
;;
n|N|no|NO)
return 1
;;
esac
echo "Please answer y or n."
done
}
sanitize_database_suffix() {
printf '%s' "${1}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/_/g; s/^_+//; s/_+$//'
}
escape_regex() {
printf '%s' "${1}" | sed -E 's/[][(){}.^$*+?|\\-]/\\&/g'
}
write_env_file() {
cat > "${TARGET_FILE}" <<EOF
APP_DOMAIN=${APP_DOMAIN}
PUBLIC_BASE_DOMAIN=${PUBLIC_BASE_DOMAIN}
TENANT_ID=${TENANT_ID}
TENANT_SUBDOMAIN=${TENANT_SUBDOMAIN}
MASTER_DATABASE_NAME=${MASTER_DATABASE_NAME}
TENANT_DATABASE_NAME=${TENANT_DATABASE_NAME}
POSTGRES_USER=${POSTGRES_USER}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
APP_SECRET=${APP_SECRET}
JWT_PASSPHRASE=${JWT_PASSPHRASE}
TRUSTED_PROXIES='${TRUSTED_PROXIES}'
TRUSTED_HOSTS='${TRUSTED_HOSTS}'
CORS_ALLOW_ORIGIN='${CORS_ALLOW_ORIGIN}'
MAILER_DSN=${MAILER_DSN}
ADMIN_ALERT_EMAIL=${ADMIN_ALERT_EMAIL}
TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}
TURNSTILE_FAIL_OPEN=${TURNSTILE_FAIL_OPEN}
PUBLIC_TURNSTILE_SITE_KEY=${PUBLIC_TURNSTILE_SITE_KEY}
SENTRY_DSN=${SENTRY_DSN}
SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
EOF
}
validate_compose_config() {
if ! command_exists docker; then
echo "docker not found, skipping compose validation."
return 0
fi
if [ ! -f "${COMPOSE_FILE}" ]; then
echo "compose.prod.yaml not found, skipping compose validation."
return 0
fi
if docker compose --env-file "${TARGET_FILE}" -f "${COMPOSE_FILE}" config >/dev/null; then
echo "docker compose validation: OK"
return 0
fi
echo "docker compose validation failed." >&2
return 1
}
load_existing_env
echo "Classeo VPS env generator"
echo
PUBLIC_IP_DEFAULT=${PUBLIC_IP:-$(detect_public_ipv4 || true)}
if [ -n "${PUBLIC_IP_DEFAULT}" ]; then
echo "Detected public IPv4: ${PUBLIC_IP_DEFAULT}"
echo
fi
DEFAULT_BASE_DOMAIN=${PUBLIC_BASE_DOMAIN:-example.com}
PUBLIC_BASE_DOMAIN=$(prompt_required "Base domain" "${DEFAULT_BASE_DOMAIN}")
DEFAULT_TENANT_SUBDOMAIN=${TENANT_SUBDOMAIN:-demo}
TENANT_SUBDOMAIN=$(prompt_required "Tenant subdomain" "${DEFAULT_TENANT_SUBDOMAIN}")
DEFAULT_APP_DOMAIN=${APP_DOMAIN:-${TENANT_SUBDOMAIN}.${PUBLIC_BASE_DOMAIN}}
APP_DOMAIN=$(prompt_required "Full application domain" "${DEFAULT_APP_DOMAIN}")
DEFAULT_ADMIN_ALERT_EMAIL=${ADMIN_ALERT_EMAIL:-admin@${PUBLIC_BASE_DOMAIN}}
ADMIN_ALERT_EMAIL=$(prompt_required "Admin alert email" "${DEFAULT_ADMIN_ALERT_EMAIL}")
DEFAULT_POSTGRES_USER=${POSTGRES_USER:-classeo}
POSTGRES_USER=$(prompt_required "PostgreSQL user" "${DEFAULT_POSTGRES_USER}")
DEFAULT_MASTER_DATABASE_NAME=${MASTER_DATABASE_NAME:-classeo_master}
MASTER_DATABASE_NAME=$(prompt_required "Master database name" "${DEFAULT_MASTER_DATABASE_NAME}")
DEFAULT_TENANT_ID=${TENANT_ID:-$(generate_uuid)}
TENANT_ID=$(prompt_required "Tenant UUID" "${DEFAULT_TENANT_ID}")
DATABASE_SUFFIX=$(sanitize_database_suffix "${TENANT_SUBDOMAIN}")
DEFAULT_TENANT_DATABASE_NAME=${TENANT_DATABASE_NAME:-classeo_tenant_${DATABASE_SUFFIX}}
TENANT_DATABASE_NAME=$(prompt_required "Tenant database name" "${DEFAULT_TENANT_DATABASE_NAME}")
DEFAULT_MAILER_DSN=${MAILER_DSN:-null://null}
MAILER_DSN=$(prompt_required "Mailer DSN" "${DEFAULT_MAILER_DSN}")
DEFAULT_SENTRY_DSN=${SENTRY_DSN:-}
SENTRY_DSN=$(prompt "Sentry DSN (optional)" "${DEFAULT_SENTRY_DSN}")
if prompt_yes_no "Use Cloudflare Turnstile test keys" "y"; then
TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"
TURNSTILE_FAIL_OPEN="true"
PUBLIC_TURNSTILE_SITE_KEY="1x00000000000000000000AA"
else
TURNSTILE_SECRET_KEY=$(prompt_required "Turnstile secret key" "${TURNSTILE_SECRET_KEY:-}")
TURNSTILE_FAIL_OPEN=$(prompt_required "Turnstile fail open (true/false)" "${TURNSTILE_FAIL_OPEN:-false}")
PUBLIC_TURNSTILE_SITE_KEY=$(prompt_required "Turnstile site key" "${PUBLIC_TURNSTILE_SITE_KEY:-}")
fi
TRUSTED_PROXIES=${TRUSTED_PROXIES:-127.0.0.1/32,172.16.0.0/12}
ESCAPED_BASE_DOMAIN=$(escape_regex "${PUBLIC_BASE_DOMAIN}")
TRUSTED_HOSTS="^(.+\\.)?${ESCAPED_BASE_DOMAIN}\$"
CORS_ALLOW_ORIGIN="^https://([\\w-]+\\.)?${ESCAPED_BASE_DOMAIN}\$"
POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-$(generate_secret 16)}
APP_SECRET=${APP_SECRET:-$(generate_secret 32)}
JWT_PASSPHRASE=${JWT_PASSPHRASE:-$(generate_secret 24)}
SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-production}
echo
echo "Summary"
echo "- App domain: ${APP_DOMAIN}"
echo "- Base domain: ${PUBLIC_BASE_DOMAIN}"
echo "- Tenant subdomain: ${TENANT_SUBDOMAIN}"
echo "- Tenant UUID: ${TENANT_ID}"
echo "- Target file: ${TARGET_FILE}"
if [ -n "${PUBLIC_IP_DEFAULT}" ]; then
echo "- Public IPv4: ${PUBLIC_IP_DEFAULT}"
fi
echo
if ! prompt_yes_no "Write ${TARGET_FILE}" "y"; then
echo "Aborted."
exit 1
fi
mkdir -p "$(dirname "${TARGET_FILE}")"
if [ -f "${TARGET_FILE}" ]; then
cp "${TARGET_FILE}" "${TARGET_FILE}.bak"
echo "Backup created: ${TARGET_FILE}.bak"
fi
write_env_file
echo "Generated ${TARGET_FILE}"
validate_compose_config
echo
echo "Next steps:"
echo "1. Review ${TARGET_FILE}"
echo "2. Run ./deploy/vps/generate-jwt.sh"
echo "3. Run docker compose --env-file ${TARGET_FILE} -f compose.prod.yaml up -d --build"

View File

@@ -0,0 +1,24 @@
#!/bin/sh
set -eu
if [ ! -f deploy/vps/.env ]; then
echo "Missing deploy/vps/.env"
exit 1
fi
JWT_PASSPHRASE=$(grep '^JWT_PASSPHRASE=' deploy/vps/.env | cut -d= -f2-)
if [ -z "$JWT_PASSPHRASE" ]; then
echo "JWT_PASSPHRASE is empty in deploy/vps/.env"
exit 1
fi
mkdir -p backend/config/jwt
openssl genrsa -aes256 -passout "pass:${JWT_PASSPHRASE}" -out backend/config/jwt/private.pem 4096
openssl rsa -pubout -passin "pass:${JWT_PASSPHRASE}" -in backend/config/jwt/private.pem -out backend/config/jwt/public.pem
chmod 600 backend/config/jwt/private.pem
chmod 644 backend/config/jwt/public.pem
echo "JWT keypair generated in backend/config/jwt"

View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
if [ -z "${TENANT_DATABASE_NAME:-}" ] || [ "${TENANT_DATABASE_NAME}" = "${POSTGRES_DB}" ]; then
exit 0
fi
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <<-EOSQL
SELECT format('CREATE DATABASE %I', '${TENANT_DATABASE_NAME}')
WHERE NOT EXISTS (
SELECT 1
FROM pg_database
WHERE datname = '${TENANT_DATABASE_NAME}'
) \gexec
EOSQL

View File

@@ -1,5 +1,12 @@
# Déploiement en Production
## Variante simple deja preparee
Pour un deploiement mono-serveur de validation ou de demo, voir :
- `docs/DEPLOYMENT_VPS1.md`
- `compose.prod.yaml`
- `deploy/vps/`
## Architecture Multi-tenant
Classeo utilise une architecture multi-tenant où chaque école a son propre sous-domaine :
@@ -176,7 +183,6 @@ php bin/console tenant:migrate <subdomain>
## TODO
- [ ] Créer un `compose.prod.yaml` pour la production
- [ ] Script de création automatique de tenant
- [ ] Interface admin pour gérer les tenants
- [ ] Monitoring et alerting

382
docs/DEPLOYMENT_VPS1.md Normal file
View File

@@ -0,0 +1,382 @@
# Deploiement VPS-1
Cette procedure sert a mettre Classeo en ligne sur une petite machine Ubuntu 24.04 pour :
- valider le comportement en production
- faire une demo
- heberger un seul tenant
Elle est volontairement minimale :
- serveur unique
- Docker Compose
- pas de Mercure
- pas de Meilisearch
- pas de RabbitMQ
- Messenger stocke sa file async en base via Doctrine
Le dossier `deploy/vps/` contient deja les fichiers necessaires :
- `deploy/vps/.env.example`
- `deploy/vps/generate-env.sh`
- `deploy/vps/Caddyfile`
- `deploy/vps/generate-jwt.sh`
- `deploy/vps/postgres/01-create-tenant-db.sh`
## 1. Prerequis
Il faut :
- un VPS Ubuntu 24.04
- un acces SSH
- un nom de domaine ou sous-domaine pointe vers le VPS
- les ports `80` et `443` accessibles depuis Internet
Exemple recommande :
- domaine final : `demo.example.com`
- tenant : `demo`
- domaine de base : `example.com`
Dans ce cas :
- `APP_DOMAIN=demo.example.com`
- `PUBLIC_BASE_DOMAIN=example.com`
- `TENANT_SUBDOMAIN=demo`
## 2. Connexion SSH
Chez OVH, l'utilisateur initial est generalement `ubuntu`.
```bash
ssh ubuntu@IP_DU_VPS
```
Si tu veux passer root pour l'installation :
```bash
sudo -i
```
## 3. Preparation du serveur
Les commandes suivantes installent Git, Docker Engine et le plugin Compose.
```bash
sudo apt update
sudo apt install -y ca-certificates curl git gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
```
Reconnecte ensuite ta session SSH, puis verifie :
```bash
docker --version
docker compose version
git --version
```
## 4. Ajouter du swap
Sur un `VPS-1`, le swap aide surtout pendant les builds Docker.
```bash
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h
```
## 5. Configurer Git en SSH
Sur le VPS, genere une cle SSH dediee :
```bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keygen -t ed25519 -C "vps-classeo" -f ~/.ssh/id_ed25519 -N ""
ssh-keyscan -H git.roukmoute.fr >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
```
Le serveur Git ecoute en SSH sur le port `2222`, pas sur `22`.
Ajoute donc cette configuration :
```bash
cat >> ~/.ssh/config <<'EOF'
Host git.roukmoute.fr
HostName git.roukmoute.fr
User git
Port 2222
IdentityFile ~/.ssh/id_ed25519
EOF
chmod 600 ~/.ssh/config
```
Affiche ensuite la cle publique :
```bash
cat ~/.ssh/id_ed25519.pub
```
Ajoute cette cle dans ton compte Gitea :
- `https://git.roukmoute.fr`
- `Settings`
- `SSH / GPG Keys`
- `Add Key`
Teste ensuite la connexion :
```bash
ssh -T git@git.roukmoute.fr
```
## 6. Cloner le projet
```bash
sudo mkdir -p /srv/classeo
sudo chown -R "$USER":"$USER" /srv/classeo
git clone git@git.roukmoute.fr:Roukmoute/Classeo.git /srv/classeo
cd /srv/classeo
```
Sans fichier `~/.ssh/config`, la variante equivalente est :
```bash
git clone ssh://git@git.roukmoute.fr:2222/Roukmoute/Classeo.git /srv/classeo
```
Si tu veux juste depanner rapidement en HTTPS, le depot public repond aussi sur :
```bash
git clone https://git.roukmoute.fr/Roukmoute/Classeo.git /srv/classeo
```
## 7. Configurer le DNS
Avant de lancer la stack, cree un enregistrement DNS de type `A` :
- `demo.example.com` -> `IPV4_DU_VPS`
Attends que le domaine reponde correctement :
```bash
dig +short demo.example.com
```
Le resultat doit renvoyer l'IPv4 du VPS.
## 8. Preparer le fichier d'environnement
Le plus simple est d'utiliser le generateur interactif :
```bash
./deploy/vps/generate-env.sh
```
Il pose les questions utiles, genere les secrets, derive les regex et ecrit `deploy/vps/.env`.
Si tu preferes toujours partir du template a la main :
Depuis la racine du repo :
```bash
cp deploy/vps/.env.example deploy/vps/.env
```
Edite ensuite `deploy/vps/.env` :
```bash
nano deploy/vps/.env
```
Valeurs a adapter au minimum :
```env
APP_DOMAIN=demo.example.com
PUBLIC_BASE_DOMAIN=example.com
TENANT_ID=remplacer-par-un-uuid
TENANT_SUBDOMAIN=demo
POSTGRES_PASSWORD=mot-de-passe-base
APP_SECRET=secret-app
JWT_PASSPHRASE=passphrase-jwt
TRUSTED_HOSTS='^(.+\.)?example\.com$'
CORS_ALLOW_ORIGIN='^https://([\w-]+\.)?example\.com$'
ADMIN_ALERT_EMAIL=admin@example.com
```
Tu peux generer les secrets ainsi :
```bash
uuidgen
openssl rand -hex 32
openssl rand -base64 48
```
Pour une simple demo, les valeurs Turnstile de test peuvent rester telles quelles dans `.env`.
## 9. Charger les variables dans le shell
Certaines commandes plus bas utilisent les variables du fichier `.env`.
Charge-les une fois dans ton shell courant :
```bash
set -a
. deploy/vps/.env
set +a
```
## 10. Generer les cles JWT
Les cles JWT ne sont pas versionnees. Il faut donc les generer sur le serveur avant le premier build.
```bash
./deploy/vps/generate-jwt.sh
```
Verifie qu'elles existent :
```bash
ls -l backend/config/jwt
```
## 11. Lancer la stack
```bash
docker compose --env-file deploy/vps/.env -f compose.prod.yaml up -d --build
```
Services demarres :
- `caddy`
- `php`
- `frontend`
- `worker`
- `db`
- `redis`
Suivre les logs au premier demarrage :
```bash
docker compose --env-file deploy/vps/.env -f compose.prod.yaml logs -f caddy php frontend worker
```
## 12. Executer les migrations
Migrations de la base master :
```bash
docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
php bin/console doctrine:migrations:migrate --no-interaction
```
Migrations de la base tenant :
```bash
docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
php bin/console tenant:migrate "${TENANT_SUBDOMAIN}"
```
Le tenant PostgreSQL est cree automatiquement au premier demarrage via `deploy/vps/postgres/01-create-tenant-db.sh`.
## 13. Creer un utilisateur de demo
Optionnel, mais pratique pour une verification complete.
```bash
docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
php bin/console app:dev:create-test-user \
--tenant="${TENANT_SUBDOMAIN}" \
--role=ADMIN \
--email=demo-admin@example.com \
--password='ChangeMe123!'
```
## 14. Verifications
Verifier les conteneurs :
```bash
docker compose --env-file deploy/vps/.env -f compose.prod.yaml ps
```
Verifier l'application :
```bash
curl -I "https://${APP_DOMAIN}"
curl -I "https://${APP_DOMAIN}/api/docs"
```
A ouvrir dans le navigateur :
- `https://demo.example.com`
- `https://demo.example.com/api/docs`
## 15. Mise a jour ulterieure
Pour redeployer sur la meme machine :
```bash
cd /srv/classeo
git pull
set -a
. deploy/vps/.env
set +a
docker compose --env-file deploy/vps/.env -f compose.prod.yaml up -d --build
docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
php bin/console doctrine:migrations:migrate --no-interaction
docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php \
php bin/console tenant:migrate "${TENANT_SUBDOMAIN}"
```
## 16. Reinstallation sur une nouvelle machine
Si tu veux remettre la meme instance sur un autre VPS, conserve au minimum :
- `deploy/vps/.env`
- `backend/config/jwt/private.pem`
- `backend/config/jwt/public.pem`
Si tu veux aussi conserver les donnees, il faut en plus sauvegarder PostgreSQL et les volumes Docker.
## 17. Depannage rapide
Si `docker compose up` se fait tuer pendant le build :
- verifie le swap avec `free -h`
- relance le build apres activation du swap
Si le domaine ne repond pas en HTTPS :
- verifie que le DNS pointe bien vers le VPS
- verifie que les ports `80` et `443` sont ouverts
- regarde les logs `caddy`
Si le frontend repond mais pas l'API :
- verifie `https://TON_DOMAINE/api/docs`
- regarde les logs `php`
- regarde les logs `worker`
## Resume ultra-court
```bash
ssh ubuntu@IP_DU_VPS
sudo apt update && sudo apt install -y ca-certificates curl git gnupg
# installer Docker
# cloner le repo
cp deploy/vps/.env.example deploy/vps/.env
set -a && . deploy/vps/.env && set +a
./deploy/vps/generate-jwt.sh
docker compose --env-file deploy/vps/.env -f compose.prod.yaml up -d --build
docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php php bin/console doctrine:migrations:migrate --no-interaction
docker compose --env-file deploy/vps/.env -f compose.prod.yaml exec php php bin/console tenant:migrate "${TENANT_SUBDOMAIN}"
```