Deployment
Primary source:
docs/DEPLOIEMENT.md(complete reference, kept in place).
Architecture
Internet
│
▼
┌─────────────────────────────────────────┐
│ Traefik │
│ (TLS Let's Encrypt, CORS MinIO) │
└──────────────────┬──────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌─────────────┐ ┌─────────────────────┐
│ Next.js │◄──────►│ MinIO (S3) │
│ port 3000 │ upload │ port 9000 (API) │
│ │ │ port 9001 (console)│
└──────┬──────┘ └─────────────────────┘
│ ▲
▼ │ GET audio (direct)
┌─────────────┐ Browser client
│ PostgreSQL │
│ port 5432 │
└─────────────┘
Role of each component
| Component | Role |
|---|---|
| Traefik | Reverse proxy, TLS, CORS middleware for MinIO |
| Next.js | Application + REST API (auth, audio upload, dev fallback proxy) |
| PostgreSQL | Business data (languages, cards, items, speakers, admins) |
| MinIO | Audio storage — audios bucket public for reading |
Direct browser → MinIO access for audio: zero load on Next.js during playback, stable URLs compatible with the Service Worker.
Prerequisites
| Component | Minimum version |
|---|---|
| Docker | ≥ 24 |
| Docker Compose | ≥ 2.20 |
| Traefik | Deployed on the external Docker network |
| GitLab Runner | With Docker access (for CI/CD) |
Environment variables
Build-time variables (NEXT_PUBLIC_*)
Embedded into the bundle during docker build. A value change requires a rebuild.
| Variable | Description | Example |
|---|---|---|
NEXT_PUBLIC_APP_URL |
Public app URL (confirmation emails) | https://racines.kiyara.net |
NEXT_PUBLIC_PWA_ENABLED |
Enable the Service Worker | true (prod), false (dev) |
AUDIO_CDN_URLis no longer a build-arg — it is a runtime variable injected at container startup.
Runtime variables (container)
Passed via env_file in docker-compose.yml. No rebuild required.
Audio CDN (critical):
| Variable | Description | Example value |
|---|---|---|
AUDIO_CDN_URL |
MinIO public URL (includes the bucket) — mandatory | https://racines-s3.id2real.net/audios |
AUDIO_PROXY_DISABLED |
Disable the fallback proxy | true (prod) |
AUDIO_CDN_DOMAIN |
Hostname for CSP — auto-derived if absent | racines-s3.id2real.net |
⚠️ The Docker entrypoint (
docker/entrypoint.sh) refuses to start ifAUDIO_CDN_URLis empty.
PostgreSQL:
| Variable | Example value |
|---|---|
PG_HOST |
postgres |
PG_PORT |
5432 |
PG_DATABASE |
racines_db |
PG_USER |
racines |
PG_PASSWORD |
[secret] |
PG_SSL |
false |
MinIO (server access for upload):
| Variable | Description |
|---|---|
MINIO_ENDPOINT |
Internal URL (Node.js → MinIO): http://minio:9000 |
MINIO_ACCESS_KEY |
Application access key |
MINIO_SECRET_KEY |
Application secret key |
MINIO_BUCKET |
Bucket name: audios |
MINIO_PUBLIC_URL |
Server-side public URL (building audio_url values) |
Application:
| Variable | Description |
|---|---|
JWT_SECRET |
JWT secret (admin/speaker sessions) |
NODE_ENV |
production |
SMTP (contact form):
| Variable | Description |
|---|---|
SMTP_HOST |
SMTP server |
SMTP_PORT |
SMTP port (587 STARTTLS, 465 SSL) |
SMTP_SECURE |
true for port 465, otherwise false |
SMTP_USER |
SMTP user |
SMTP_PASS |
SMTP password |
SMTP_FROM_EMAIL |
Sender address |
ADMIN_EMAIL_1/2/3 |
Recipients for contact messages |
.app_env file format
# All values in double quotes (mandatory for values with #, =, spaces)
AUDIO_CDN_URL="https://racines-s3.id2real.net/audios"
JWT_SECRET="a_long_random_generated_secret"
PG_PASSWORD="postgres_password"
MINIO_SECRET_KEY="minio_secret_key"
GitLab CI/CD pipeline
Stages
push branch
│
├── test
│ ├── test:quality:sonarqube (main/develop/qa/test-ci + tags)
│ └── test:build:docker (MR only — builder target)
│
├── containerize
│ ├── package:image:dev (dev branch)
│ ├── package:image:staging (develop/qa/test-ci)
│ └── package:image:prod (main + tags)
│
└── deploy
├── deploy:int:ansible (develop/test-ci → staging, automatic)
├── deploy:dev:ansible (dev → dev, automatic)
└── deploy:prod:manual (main/tags → prod, ⚠️ MANUAL trigger)
A single image per commit — identical for all environments. The MinIO CDN URL is injected at container startup.
Docker images
| Job | Branch(es) | Image tag |
|---|---|---|
package:image:dev |
dev |
dev |
package:image:staging |
develop, qa, test-ci |
develop / qa |
package:image:prod |
main, tags |
main / v1.2.3 |
GitLab CI/CD variables
To define in GitLab → Settings → CI/CD → Variables:
| Variable | Description |
|---|---|
DOCKER_REG_URL |
Docker registry URL |
DOCKER_REG_LOGIN |
Registry login |
DOCKER_REG_PASS |
Registry password |
SSHKEY_CI |
SSH key → staging |
SSHKEY_DEV |
SSH key → dev |
DEPLOY_SSHKEY_FILE |
SSH key → production |
SONAR_HOST_URL |
SonarQube URL (quality analysis) |
Production deployment
GitLab → Pipelines → pipeline on main → deploy:prod:manual → ▶ (click Play)
Deployment is manual: no automatic production deployments.
MinIO infrastructure
Bucket and policies
mc alias set minio https://racines-s3.id2real.net ACCESS_KEY SECRET_KEY
# Create bucket with versioning
mc mb minio/audios --ignore-existing
mc version enable minio/audios
# Public read (browser → audio without auth)
mc anonymous set download minio/audios
# Application account (upload/delete, no admin rights)
mc admin user add minio app_racines_rw STRONG_PASSWORD
mc admin policy attach minio readwrite --user app_racines_rw
MinIO CORS via Traefik
MinIO AGPL does not support CORS configuration via the S3 API. CORS is handled by a Traefik middleware:
labels:
- traefik.http.routers.racines-minio-api.middlewares=minio-cors
- traefik.http.middlewares.minio-cors.headers.accesscontrolalloworiginlist=https://racines.kiyara.net,https://staging.racinesbykiyara.net
- traefik.http.middlewares.minio-cors.headers.accesscontrolallowmethods=GET,HEAD,OPTIONS
- traefik.http.middlewares.minio-cors.headers.accesscontrolallowheaders=*
- traefik.http.middlewares.minio-cors.headers.accesscontrolmaxage=86400
Add the domain of each new environment to
accesscontrolalloworiginlist.
MinIO healthcheck
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3
⚠️ Use
mc ready local— notmc admin info(requires admin rights) norcurl(absent from the image).
Dockerfile (multi-stage)
# Stage 1: deps — Node.js dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: builder — Next.js build
FROM node:20-alpine AS builder
ARG NEXT_PUBLIC_PWA_ENABLED=true
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_PWA_ENABLED=$NEXT_PUBLIC_PWA_ENABLED
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
WORKDIR /app
COPY . .
RUN npm ci && NODE_ENV=production npm run build
# Remove SW if PWA is disabled
RUN if [ "$NEXT_PUBLIC_PWA_ENABLED" != "true" ]; then \
rm -f public/sw*.js public/register-sw*; \
fi
# Stage 3: runner — final image (standalone)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY docker/entrypoint.sh ./entrypoint.sh
RUN chmod +x entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["./entrypoint.sh"]
Production build:
docker build \
--build-arg NEXT_PUBLIC_PWA_ENABLED=true \
--build-arg NEXT_PUBLIC_APP_URL=https://racines.kiyara.net \
-t registry.id2real.net/racines-app:main .
First deployment
1. Prepare the server
mkdir -p /docker-apps/racines
cd /docker-apps/racines
# Copy the environment's docker-compose file (provided by Ansible)
cp ansible/docker-compose.integration.yaml docker-compose.yml
# Create the variables file
cp .env.example .app_env
nano .app_env # Fill in all values (double quotes mandatory)
2. Configure MinIO
# Start MinIO only
docker compose up -d minio
# Configure the bucket
docker exec -it racines-minio mc alias set local http://localhost:9000 root root_password
docker exec -it racines-minio mc mb local/audios --ignore-existing
docker exec -it racines-minio mc version enable local/audios
docker exec -it racines-minio mc anonymous set download local/audios
3. Initialize the database
Migrations are in the migrations/ folder:
# Start PostgreSQL only
docker compose up -d postgres
# Apply migrations
docker exec -i racines-postgres psql -U racines -d racines_db \
< migrations/001_initial_schema.sql
docker exec -i racines-postgres psql -U racines -d racines_db \
< migrations/002_create_admins.sql
docker exec -i racines-postgres psql -U racines -d racines_db \
< migrations/004_quiz_results.sql
003_idb_card_id_index_note.sqlis a documentation note — do not execute it.
4. Create the first admin account
# On the server, once the app container is running
docker exec -it racines-app npm run create-admin
# → Enter email and password
Or from a development machine with database access:
npm run create-admin
5. Launch the application
docker compose pull
docker compose up -d
docker compose logs -f app
6. Verify startup
# Next.js liveness probe
curl https://racines.kiyara.net/api/health/live
# → {"status":"ok"}
# Direct MinIO audio access
curl -I https://racines-s3.id2real.net/audios/pul/item-uuid/audio.mp3
# → HTTP 200, Content-Type: audio/mpeg
# Audio CORS
curl -I -H "Origin: https://racines.kiyara.net" \
https://racines-s3.id2real.net/audios/pul/item-uuid/audio.mp3
# → Access-Control-Allow-Origin: https://racines.kiyara.net
Updates
Via CI/CD pipeline (normal method)
git push origin develop # → build + automatic staging deploy
git push origin main # → prod build, then MANUAL deploy
Manual update on the server
cd /docker-apps/racines
docker compose pull app
docker compose up -d --force-recreate app
After an environment variable change only
# Edit .app_env on the server, then:
docker compose up -d --force-recreate app
# No rebuild needed — AUDIO_CDN_URL is read at container startup
PWA and deployments
Impact of a deployment on offline users
- Users with a downloaded language continue using it offline (old version in cache).
- On the next online load, the new SW installs → invalidates old caches (
racines-cache-vXX→racines-cache-vXX+1). - Re-downloading the language updates the offline content.
If AUDIO_CDN_URL changes between deployments
Previously cached audio (keys = absolute URLs) becomes orphaned. Users must re-download the language to have offline audio with the new URLs.
Post-deployment checklist
[ ] GET /api/health/live → {"status":"ok"}
[ ] Admin interface accessible → /admin → login page
[ ] Admin login works → valid credentials accepted
[ ] Language list → /api/languages → valid JSON
[ ] Direct MinIO audio → curl -I [AUDIO_CDN_URL]/pul/uuid/audio.mp3 → 200
[ ] MinIO CORS → Access-Control-Allow-Origin present
[ ] No CSP violations → DevTools → Console, no CSP errors
[ ] Audio playback on a card → 🔊 button works
[ ] PWA installable → install icon visible in the browser
[ ] Offline download → download a language, airplane mode, navigate
[ ] Contact form → submission → email received + success toast
[ ] Statistics tracked → actions in the app → admin dashboard updated
Troubleshooting
Container refuses to start: AUDIO_CDN_URL must be set
Cause: Variable missing from docker-compose.yml or .app_env.
Fix: Add AUDIO_CDN_URL="https://[CDN]/audios" and restart. No rebuild needed.
Audio blocked by CSP
Symptom: Refused to connect to 'https://s3.example.com/...' because it violates CSP
Cause: AUDIO_CDN_DOMAIN is incorrect or missing (auto-derivation failed).
Fix: Explicitly set AUDIO_CDN_DOMAIN="s3.example.com" and restart.
CORS error on audio
Symptom: Access-Control-Allow-Origin missing from MinIO response headers.
Cause: App domain missing from the Traefik minio-cors middleware.
Fix: Add the domain to accesscontrolalloworiginlist in Traefik labels.
MinIO healthcheck unhealthy: Access Denied
Cause: mc admin info command used (requires admin rights).
Fix: Use mc ready local in the healthcheck.
SW not updated after deployment
Symptom: User sees the old version.
Fix: Hard refresh (Ctrl+Shift+R) or DevTools → Application → Service Workers → Unregister.
Offline page → returns to home instead of the card
Possible cause 1: The page was not pre-cached (download interrupted).
Possible cause 2: The SW is not yet the controller (first install).
Fix: Reload online to activate the new SW, then re-download the language.