From 9c26628561f9cb364a80d6902bead62d3019cbcb Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Tue, 10 Mar 2026 18:30:45 +0100 Subject: [PATCH] feat(deploy): add vps deployment assets --- README.md | 14 + compose.prod.yaml | 159 +++++++++ deploy/vps/.env.example | 26 ++ deploy/vps/Caddyfile | 15 + deploy/vps/generate-env.sh | 283 +++++++++++++++ deploy/vps/generate-jwt.sh | 24 ++ deploy/vps/postgres/01-create-tenant-db.sh | 15 + docs/DEPLOYMENT.md | 8 +- docs/DEPLOYMENT_VPS1.md | 382 +++++++++++++++++++++ 9 files changed, 925 insertions(+), 1 deletion(-) create mode 100644 compose.prod.yaml create mode 100644 deploy/vps/.env.example create mode 100644 deploy/vps/Caddyfile create mode 100755 deploy/vps/generate-env.sh create mode 100644 deploy/vps/generate-jwt.sh create mode 100644 deploy/vps/postgres/01-create-tenant-db.sh create mode 100644 docs/DEPLOYMENT_VPS1.md diff --git a/README.md b/README.md index d1f123c..9143893 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..3f4de41 --- /dev/null +++ b/compose.prod.yaml @@ -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: diff --git a/deploy/vps/.env.example b/deploy/vps/.env.example new file mode 100644 index 0000000..ea52556 --- /dev/null +++ b/deploy/vps/.env.example @@ -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 diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile new file mode 100644 index 0000000..0eacf78 --- /dev/null +++ b/deploy/vps/Caddyfile @@ -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 + } +} diff --git a/deploy/vps/generate-env.sh b/deploy/vps/generate-env.sh new file mode 100755 index 0000000..dfb8f64 --- /dev/null +++ b/deploy/vps/generate-env.sh @@ -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}" </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" diff --git a/deploy/vps/generate-jwt.sh b/deploy/vps/generate-jwt.sh new file mode 100644 index 0000000..3a5f989 --- /dev/null +++ b/deploy/vps/generate-jwt.sh @@ -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" diff --git a/deploy/vps/postgres/01-create-tenant-db.sh b/deploy/vps/postgres/01-create-tenant-db.sh new file mode 100644 index 0000000..33f0e89 --- /dev/null +++ b/deploy/vps/postgres/01-create-tenant-db.sh @@ -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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index fa95e85..d78c20a 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 ## 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 diff --git a/docs/DEPLOYMENT_VPS1.md b/docs/DEPLOYMENT_VPS1.md new file mode 100644 index 0000000..b87b2e1 --- /dev/null +++ b/docs/DEPLOYMENT_VPS1.md @@ -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}" +```