Construire un Chat : Chapitre 5 — Déploiement avec Kamal

Tony Duong

Tony Duong

mai 31, 20266 min

Aussi disponible en:🇬🇧🇯🇵
#kamal#docker#hetzner#deployment#github-actions
Construire un Chat : Chapitre 5 — Déploiement avec Kamal

💬 C'est le module de chat en direct que tu vois en bas à droite de ce site. Cette série détaille précisément comment je l'ai construit — clique sur la bulle de chat pour l'essayer, puis lis la suite pour découvrir comment ça fonctionne en coulisses.

Le serveur de chat fonctionne en local. Maintenant il doit fonctionner sur Internet. Ce chapitre couvre le build Docker, le déploiement Kamal 2 sur un VPS Hetzner, la gestion des secrets, le CI/CD, et la configuration CORS/WebSocket en production.

L'architecture à deux hôtes

Vercel (offre gratuite)                  Hetzner CX22 (~3,49 EUR/mois)
─────────────────                     ───────────────────────────
Next.js 16 frontend                   Rails 8 + Puma + Thruster
  Pages statiques, UI admin             ActionCable WebSocket
  JS du widget de chat                  API REST, bases SQLite
                                        Solid Cable / Queue / Cache
        <── wss://nikki-chat.shirimono.fun/cable ──>
        <── https://nikki-chat.shirimono.fun/* ──>

Différence clé avec le Chapitre 1 : la production utilise wss:// et https://. Le TLS se termine au niveau de kamal-proxy via Let's Encrypt, pas au niveau de Rails.

Le Dockerfile

chat-server/Dockerfile — build multi-stage de 74 lignes.

Étape Ce qu'elle fait
base ruby:3.2.2-slim + sqlite3 + jemalloc
build Ajoute build-essential, exécute bundle install, précompile bootsnap
finale Copie les gems + l'app depuis build, s'exécute en non-root (uid 1000)

Variables d'environnement importantes définies dans l'étape base :

ENV RAILS_ENV="production" \
    BUNDLE_WITHOUT="development" \
    LD_PRELOAD="/usr/local/lib/libjemalloc.so"

LD_PRELOAD force jemalloc, réduisant la fragmentation mémoire sur un VPS de 4 Go. BUNDLE_WITHOUT exclut les gems de développement de l'image.

L'entrypoint (chat-server/bin/docker-entrypoint) exécute db:prepare avant le démarrage. Le CMD lance Thruster devant Puma :

CMD ["./bin/thrust", "./bin/rails", "server"]

Thruster est un reverse proxy Go gérant HTTP/2, gzip et X-Sendfile.

Configuration Kamal 2

chat-server/config/deploy.yml :

service: chat-server
image: tonystrawberry/chat-server

servers:
  web:
    - 178.104.231.154

proxy:
  ssl: true
  host: nikki-chat.shirimono.fun

proxy.ssl: true provisionne un certificat Let's Encrypt au premier déploiement. Pas de nginx, pas de certbot.

env:
  secret:
    - RAILS_MASTER_KEY
    - ADMIN_USER
    - ADMIN_PASSWORD
    - VAPID_PUBLIC_KEY
    - VAPID_PRIVATE_KEY
    - VAPID_SUBJECT
  clear:
    SOLID_QUEUE_IN_PUMA: true
    ALLOWED_ORIGINS: "https://nikki-tony.vercel.app"
    RAILS_LOG_LEVEL: info

Les valeurs secret sont injectées au moment du déploiement (jamais dans l'image). SOLID_QUEUE_IN_PUMA active le plugin Puma à la ligne 38 de config/puma.rb — les jobs en arrière-plan s'exécutent dans Puma, pas besoin d'un processus worker séparé.

volumes:
  - "chat_server_storage:/rails/storage"

Critique : les bases de données SQLite vivent dans /rails/storage. Sans ce volume nommé, chaque déploiement efface la base de données.

builder:
  arch: amd64

Sur Apple Silicon, Docker Buildx fait de la cross-compilation via QEMU.

Gestion des secrets

chat-server/.kamal/secrets est commité dans git mais ne contient que des références de variables :

KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$RAILS_MASTER_KEY
ADMIN_USER=$ADMIN_USER
ADMIN_PASSWORD=$ADMIN_PASSWORD

Les valeurs réelles proviennent de l'environnement shell. Pour les déploiements locaux, exportez depuis un .env (gitignored). Pour le CI, elles viennent des secrets GitHub Actions.

CI/CD GitHub Actions

Chaque push sur main déclenche le déploiement :

on:
  push:
    branches: [main]

concurrency:
  group: deploy
  cancel-in-progress: true

concurrency garantit qu'un seul déploiement s'exécute à la fois — poussez deux fois rapidement et le premier est annulé.

Les étapes du job : checkout, setup Ruby (avec cache), setup Docker Buildx, installation de la clé SSH, ajout du serveur aux known_hosts, exécution de bin/kamal deploy. Secrets GitHub requis : SSH_PRIVATE_KEY, KAMAL_REGISTRY_USERNAME, KAMAL_REGISTRY_PASSWORD, RAILS_MASTER_KEY, ADMIN_USER, ADMIN_PASSWORD, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT.

Configuration CORS et WebSocket en production

Trois variables d'env sur Vercel connectent le frontend à Rails :

NEXT_PUBLIC_CHAT_WS_URL=wss://nikki-chat.shirimono.fun/cable
NEXT_PUBLIC_CHAT_HTTP_URL=https://nikki-chat.shirimono.fun
NEXT_PUBLIC_VAPID_PUBLIC_KEY=<la clé publique VAPID>

Côté Rails, ALLOWED_ORIGINS dans deploy.yml doit inclure le domaine Vercel. L'initialiseur CORS (chat-server/config/initializers/cors.rb) sépare par virgules :

origins(*ENV.fetch("ALLOWED_ORIGINS", "http://localhost:3000").split(",").map(&:strip))

Le flux de déploiement complet :

git push main -> GitHub Actions -> bin/kamal deploy
  -> Docker build (amd64) -> push vers ghcr.io
  -> SSH vers Hetzner -> pull de l'image -> exécution de db:prepare
  -> Démarrage Puma + Thruster -> kamal-proxy provisionne le SSL
  -> Health check (GET /up) réussit -> ancien conteneur arrêté

Échange sans temps d'arrêt. Le flux complet prend environ 3 minutes.

Pour aller plus loin

  • Rate limiting — Ajouter un throttling par session à VisitorChannel (max 1 msg/sec). Rejeter l'excès avec un broadcast too_fast.
  • Indicateurs de frappe — Broadcaster un événement typing, auto-effacement après 3 secondes. Juste un autre type de broadcast comme montré au Chapitre 2.
  • Pièces jointes — Active Storage + S3. Accepter les images dans le chat, afficher les miniatures dans le widget.
  • Plusieurs admins — Remplacer l'authentification par variables d'env par une table users et bcrypt. L'authentification AdminChannel du Chapitre 2 vérifierait un enregistrement en base de données à la place.

A vous de jouer

1. Exécuter l'image de production en local

cd chat-server
docker build -t chat-server .
docker run \
  -e RAILS_MASTER_KEY=$(cat config/master.key) \
  -e ADMIN_USER=test \
  -e ADMIN_PASSWORD=test \
  -e ALLOWED_ORIGINS=http://localhost:3000 \
  -p 3100:80 \
  chat-server

Définissez NEXT_PUBLIC_CHAT_HTTP_URL=http://localhost:3100 et NEXT_PUBLIC_CHAT_WS_URL=ws://localhost:3100/cable dans .env.local. Utilisez ws:// et non wss:// — il n'y a pas de TLS en local. Sur Apple Silicon, ajoutez --platform linux/amd64 si le build segfault pendant la précompilation de bootsnap.

2. Ajouter une nouvelle variable d'env au pipeline de déploiement

Ajoutez RATE_LIMIT_PER_MINUTE=30 comme variable d'env claire dans deploy.yml. Vérifiez avec bin/kamal app exec 'printenv RATE_LIMIT_PER_MINUTE'.

Ajoutez dans la section clear de chat-server/config/deploy.yml :

  clear:
    SOLID_QUEUE_IN_PUMA: true
    ALLOWED_ORIGINS: "https://nikki-tony.vercel.app"
    RAILS_LOG_LEVEL: info
    RATE_LIMIT_PER_MINUTE: 30

Comme c'est une valeur claire (non secrète), .kamal/secrets n'a pas besoin de mise à jour. Déployez, puis vérifiez avec bin/kamal app exec 'printenv RATE_LIMIT_PER_MINUTE'.

3. Inspecter le volume Docker sur le serveur

Connectez-vous en SSH et trouvez où les fichiers SQLite se trouvent sur le disque.

ssh root@178.104.231.154 "docker volume inspect chat_server_storage"

Le Mountpoint (par ex., /var/lib/docker/volumes/chat_server_storage/_data) contient production.sqlite3, production_cache.sqlite3, production_queue.sqlite3, et production_cable.sqlite3.

4. Simuler un health check échoué

Renommez GET /up en GET /health-check dans config/routes.rb et déployez.

Kamal interroge GET /up après avoir démarré le nouveau conteneur. Avec la route renommée, il reçoit des 404, expire après 30 secondes, déclare le conteneur non sain, l'arrête, et garde l'ancien conteneur en fonctionnement. Vous verrez Container is not healthy dans la sortie du déploiement. Revertez et redéployez pour corriger.


🌐 Traduit par Claude

Tony Duong

Par Tony Duong

Un journal intime numérique. Pensées, expériences et réflexions.