Conteneurs et réseaux
Tous les services tournent dans des conteneurs Docker, décrits par des fichiers Docker Compose. Cette page montre ce qui tourne sur chaque machine, comment les conteneurs se parlent, et comment leurs images sont construites.
Sur tm-prod
| Conteneur | Image | Joignable par |
|---|---|---|
timemanager-traefik | traefik:v3.3 | internet (80, 443) |
timemanager-front | ghcr.io/<owner>/timemanager-web:latest | Traefik seulement |
timemanager-docs | ghcr.io/<owner>/timemanager-docs:latest | Traefik seulement |
timemanager-back | ghcr.io/<owner>/timemanager-api:latest | Traefik seulement |
timemanager-db | postgres:17-alpine | l'API seulement |
- La base est branchée uniquement sur le réseau
internal, déclaré avecinternal: true: elle n'a aucun accès à internet et Traefik ne peut pas la joindre. - L'API est branchée sur les deux réseaux : Traefik la joint d'un côté, elle joint la base de l'autre.
- L'API ne démarre qu'une fois PostgreSQL prêt, grâce à un
healthchecksur la base et àdepends_on: condition: service_healthy. - Tous les conteneurs ont
restart: unless-stopped: ils repartent tout seuls après un redémarrage de la VM.
Les données
Les données PostgreSQL sont dans le volume nommé app_pgdata, sur le disque de tm-prod, dans /var/lib/docker/volumes/app_pgdata/_data.
Il survit à la recréation des conteneurs, aux redéploiements et aux redémarrages de la VM. Il ne protège pas contre la perte du disque ou de la VM : voir Limites et améliorations.
Sur tm-tooling
| Conteneur | Image | Joignable par |
|---|---|---|
jenkins | image maison basée sur jenkins/jenkins:lts | l'IP interne de tm-tooling, port 8080 |
L'image ajoute le client Docker et les plugins de jenkins/Plugins/plugins.txt. Jenkins construit les images à travers le socket Docker de la machine. Voir Jenkins.
Les images du projet
| Image | Construite depuis | Par | Contient |
|---|---|---|---|
ghcr.io/saliehsab/timemanager-web | timemanager-web/Dockerfile | Jenkins | Le site compilé, servi par nginx |
ghcr.io/saliehsab/timemanager-api | timemanager-api/Dockerfile | Jenkins | La release Elixir de l'API |
postgres:17-alpine | Docker Hub | La base de données | |
traefik:v3.3 | Docker Hub | Le reverse proxy | |
| Image Jenkins locale | timemanager-devops/jenkins/Dockerfile | Ansible, sur tm-tooling | Jenkins + Docker + plugins |
Chaque image du projet est publiée avec deux étiquettes : le hash court du commit (a1b2c3d) et latest.
L'image du frontend
timemanager-web/Dockerfile construit l'image en trois étapes (« multi-stage build ») :
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG VITE_API_URL
RUN test -n "$VITE_API_URL" || (echo "VITE_API_URL build argument is required" && exit 1)
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
FROM nginxinc/nginx-unprivileged:stable-alpine AS runner
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 8080| Étape | Rôle | Gardée dans l'image finale ? |
|---|---|---|
deps | Installer les dépendances. Docker la garde en cache tant que package.json ne change pas. | Non |
builder | Compiler le site avec Vite. Échoue si VITE_API_URL n'est pas fourni. | Non |
runner | Servir les fichiers compilés avec nginx | Oui |
L'image finale ne contient ni Node, ni le code source, ni les dépendances : seulement nginx et les fichiers HTML, CSS et JS. Elle est petite et a peu de failles possibles.
nginx-unprivileged tourne sans être root, d'où le port 8080 (les ports sous 1024 demandent les droits root).
L'adresse de l'API est figée au build
Vite remplace import.meta.env.VITE_API_URL par sa valeur pendant la compilation. Changer l'adresse de l'API demande donc de reconstruire l'image. C'est pour ça que Jenkins passe --build-arg VITE_API_URL=....
La configuration nginx
server {
listen 8080;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}try_files ... /index.html: le site utilise vue-router en mode « history ». Une adresse comme/clock/12n'est pas un vrai fichier : nginx renvoieindex.htmlet c'est Vue qui affiche la bonne page./assets/: les fichiers compilés ont un hash dans leur nom (index-a1b2c3.js). Leur contenu ne change jamais, donc le navigateur peut les garder un an.server_tokens off: nginx ne dit pas sa version.
L'image du backend
timemanager-api/Dockerfile a été généré avec mix phx.gen.release --docker, puis adapté. Il utilise deux étapes :
| Étape | Image de base | Rôle |
|---|---|---|
builder | hexpm/elixir:1.17.3-erlang-26.2.5.16-debian-trixie-... | Télécharger les dépendances, compiler, produire une release (mix release) |
final | debian:trixie-...-slim | Seulement la release et les bibliothèques système nécessaires |
Une release Elixir est un dossier autonome qui contient le code compilé et la machine virtuelle Erlang. L'image finale n'a pas besoin d'Elixir.
L'étape finale lance apt-get upgrade pour appliquer les correctifs de sécurité, et l'image tourne avec l'utilisateur nobody, sans droits.
Le démarrage : entrypoint.sh
#!/bin/sh
set -e
/app/bin/migrate
exec /app/bin/serverÀ chaque démarrage du conteneur, les migrations de base de données sont appliquées, puis le serveur démarre. Un déploiement qui ajoute une table n'a donc rien de spécial à faire. set -e arrête tout si la migration échoue : le serveur ne démarre pas sur une base incohérente.
L'étape builder sert aussi aux tests
Le pipeline backend construit seulement l'étape builder (--target builder) pour lancer mix test dedans. Voir Pipelines.
Le fichier de production : app/docker-compose.yml
services:
front:
image: ghcr.io/${GHCR_OWNER}/timemanager-web:latest
container_name: timemanager-front
restart: unless-stopped
networks: [timemanager]
back:
image: ghcr.io/${GHCR_OWNER}/timemanager-api:latest
container_name: timemanager-back
restart: unless-stopped
environment:
DATABASE_URL: ecto://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_DB}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
PHX_HOST: ${PHX_HOST}
CORS_ORIGINS: ${CORS_ORIGINS}
BREVO_API_KEY: ${BREVO_API_KEY}
MAIL_FROM: ${MAIL_FROM}
depends_on:
db:
condition: service_healthy
networks: [timemanager, internal]
db:
image: postgres:17-alpine
container_name: timemanager-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
networks: [internal]
volumes:
pgdata:
networks:
timemanager:
external: true
internal:
internal: true| Élément | Explication |
|---|---|
${GHCR_OWNER}, ${POSTGRES_...} | Lues dans le fichier .env à côté, écrit par Ansible |
container_name | Un nom fixe, utilisé par Traefik pour joindre le conteneur |
restart: unless-stopped | Redémarre tout seul après un plantage ou un redémarrage de la machine |
depends_on + service_healthy | L'API attend que PostgreSQL réponde vraiment avant de démarrer (sinon la migration échouerait) |
healthcheck | Toutes les 5 s, pg_isready vérifie que la base accepte des connexions |
@db dans DATABASE_URL | Le nom du service : Docker le traduit en adresse |
volumes: pgdata | Les données de la base survivent à la suppression du conteneur. Compose préfixe le nom par celui du dossier (app) : sur la machine, le volume s'appelle app_pgdata. |
external: true | Le réseau timemanager existe déjà (créé par le rôle traefik) |
internal: true | Le réseau internal n'a pas d'accès vers l'extérieur |
Les variables de l'API
timemanager-api/config/runtime.exs lit ces variables au démarrage :
| Variable | Obligatoire | Fournie en prod | Rôle |
|---|---|---|---|
DATABASE_URL | Oui | Oui | Connexion à PostgreSQL |
SECRET_KEY_BASE | Oui | Oui | Clé de signature de Phoenix |
PHX_HOST | Non | Oui | Le nom de domaine de l'API |
CORS_ORIGINS | Oui (ou WEB_URL) | Oui | Les sites autorisés à appeler l'API avec des cookies |
JWT_SECRET | Non | Non | Clé des jetons. Sans elle : SECRET_KEY_BASE |
WEB_URL | Non | Non | L'adresse du site dans les emails. Sans elle : le premier CORS_ORIGINS |
BREVO_API_KEY | Non | Oui | Clé du service d'envoi d'emails (Brevo). Sans elle, les emails sont seulement écrits dans les journaux. |
MAIL_FROM | Non | Oui | L'expéditeur. Sans elle : no-reply@<PHX_HOST> |
PORT | Non | Non | Par défaut 4000 |
POOL_SIZE | Non | Non | Nombre de connexions à la base |