Skip to content

Installation en production (Docker)

Cette fiche décrit comment installer TimePick avec Docker : d'abord le chemin recommandé — Docker Compose, avec l'application et sa base PostgreSQL en conteneurs liés —, puis les mécanismes Docker génériques (construction de l'image, docker run contre une base externe) valables sur n'importe quel orchestrateur. Pour un déploiement guidé pas à pas sur un VPS avec Coolify (interface graphique, HTTPS automatique, volumes, healthcheck), voir Déploiement sur un VPS avec Coolify.

Vue d'ensemble

L'image TimePick est un monolithe : elle sert à la fois l'interface web (SPA React buildée) et l'API (/api) sur le même port (3000). Elle est publiée et publique sur GHCR depuis la version v0.29.0 (multi-architecture amd64 + arm64 — voir la section « Alternative : tirer l'image publiée (GHCR) » plus bas), et peut aussi être construite depuis le Dockerfile à la racine du dépôt. Il n'y a pas de PostgreSQL embarqué dans l'image : le conteneur attend une base PostgreSQL joignable via DATABASE_URL — fournie par le conteneur db du kit Compose ci-dessous (recommandé), ou par une base externe existante.

Installation recommandée : Docker Compose (app + PostgreSQL)

Le scénario auto-hébergé le plus courant — l'application et sa base PostgreSQL en conteneurs liés sur la même machine — se décrit en un seul fichier Docker Compose : réseau interne entre les deux conteneurs, base non exposée sur l'hôte, volumes persistants, démarrage ordonné. Le dépôt fournit ce kit prêt à l'emploi dans deploy/compose/ (compose.yaml + .env.example + README), testé en conditions réelles avec l'image publiée.

Prérequis : Docker et Docker Compose v2 (docker compose version) — voir Prérequis.

yaml
# TimePick — auto-hébergement « pur Docker » : app + PostgreSQL en conteneurs liés.
# Quickstart : `cp .env.example .env` (renseigner chaque valeur) puis `docker compose up -d`.
name: timepick

services:
  app:
    # Tag ÉPINGLÉ — ne jamais utiliser `latest` en production (contenu mouvant,
    # redéploiement imprévisible). Mise à jour : sauvegarder la base, éditer ce
    # tag, puis `docker compose pull && docker compose up -d`.
    image: ghcr.io/timepick-app/timepick:v0.32.1
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      # Port HTTP unique : SPA + /api + /uploads + /health.
      - "${TIMEPICK_PORT:-3000}:3000"
    environment:
      # `db` = nom du service PostgreSQL ci-dessous (résolution DNS interne Compose).
      DATABASE_URL: postgresql://timepick:${POSTGRES_PASSWORD:?voir .env.example}@db:5432/timepick
      # Secrets requis en variables d'environnement (doctrine production) : aucun
      # état secret caché dans un volume → sauvegarde/migration = .env + volumes.
      JWT_SECRET: ${JWT_SECRET:?générer avec `openssl rand -hex 32` — voir .env.example}
      ENCRYPTION_KEY: ${ENCRYPTION_KEY:?générer avec `openssl rand -hex 32` — voir .env.example}
      APP_URL: ${APP_URL:?URL publique de l'instance — voir .env.example}
      EMAIL_FROM: ${EMAIL_FROM:?adresse d'expédition des emails — voir .env.example}
    volumes:
      # Images uploadées dans l'éditeur d'emails — perdues à la recréation du
      # conteneur sans ce volume (STORAGE_DRIVER=local, le défaut).
      - uploads:/app/server/uploads
    healthcheck:
      # curl est présent dans l'image runtime. /health répond 200 même si le
      # SMTP n'est pas encore configuré (statut "degraded") — sonde fiable.
      test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: timepick
      POSTGRES_USER: timepick
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?voir .env.example}
    volumes:
      - pgdata:/var/lib/postgresql/data
    # AUCUN `ports:` volontairement : la base n'est joignable que depuis le
    # réseau interne Compose (surface d'attaque minimale, pas de conflit avec
    # un PostgreSQL déjà présent sur l'hôte). Accès ponctuel :
    #   docker compose exec db psql -U timepick -d timepick
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U timepick -d timepick"]
      interval: 5s
      timeout: 5s
      retries: 12

volumes:
  pgdata:
  uploads:

Les choix de ce fichier :

  • Tag épinglé (v0.29.0) — jamais latest en production : une mise à jour doit être un geste volontaire (voir plus bas).
  • Base non publiée sur l'hôte — le service db n'a aucun ports: : PostgreSQL n'est joignable que depuis le réseau interne Compose. Pas de surface d'attaque réseau supplémentaire, et pas de conflit avec un PostgreSQL déjà installé sur la machine.
  • Démarrage ordonnédepends_on avec condition: service_healthy : l'application ne démarre qu'une fois PostgreSQL prêt (pg_isready), puis applique elle-même bootstrap et migrations (idempotents) avant d'écouter.
  • Secrets exigés en variables d'environnementJWT_SECRET et ENCRYPTION_KEY sont requis dans .env (génération : openssl rand -hex 32, une exécution par secret) plutôt qu'auto-générés dans un volume : c'est la configuration recommandée en production (voir Variables d'environnement), et la sauvegarde se réduit alors à .env plus les deux volumes.
  • Deux volumes nomméspgdata (la base) et uploads (images de l'éditeur d'emails).

Démarrage, depuis une copie du dossier du kit :

bash
cp .env.example .env   # puis renseigner chaque valeur — le fichier documente comment
docker compose up -d

Toute variable manquante dans .env fait échouer la commande immédiatement, avec le nom de la variable en cause. Vérifier ensuite :

bash
curl http://localhost:3000/health

Réponse 200 attendue, avec un statut ok — ou degraded tant que le SMTP n'est pas configuré, ce qui est le cas normal juste après l'installation.

Enchaîner immédiatement : premier admin, puis SMTP

Dès que /health répond, ouvrir l'application dans un navigateur : elle redirige vers l'assistant de configuration (/setup, création du premier administrateur) — à dérouler immédiatement, ces routes étant publiques tant qu'aucun admin n'existe (voir Configuration initiale). La connexion à TimePick passe ensuite exclusivement par des liens envoyés par email : sans serveur SMTP configuré, personne ne peut se connecter — voir SMTP — Fournisseurs.

Mise à jour : sauvegarder la base, éditer le tag dans compose.yaml, puis docker compose pull && docker compose up -d — détail dans Mettre à jour une instance. Sauvegardes : dump pg_dump via docker compose exec db, volume uploads, et le fichier .env (il contient ENCRYPTION_KEY) — procédures dans Sauvegarde et restauration. HTTPS : placer un reverse proxy devant le port publié et faire de cette URL publique la valeur d'APP_URL. Enfin, une seule instance (scale = 1) — les limitations de débit sont maintenues en mémoire, par processus.

down -v détruit les données

docker compose down arrête et retire les conteneurs sans toucher aux volumes. En revanche, docker compose down -v supprime aussi les volumes pgdata et uploads : toute la base et les images uploadées sont perdues, sans rollback possible. Ne l'utiliser que pour repartir volontairement de zéro.

Les sections suivantes détaillent les mécanismes sous-jacents — construction de l'image, image publiée GHCR, docker run contre une base PostgreSQL externe — utiles pour un autre orchestrateur que Compose ou pour un PostgreSQL déjà existant.

Construction de l'image (multi-stage)

Le Dockerfile est organisé en quatre étapes (stages), chacune nommée :

  1. client-build — installe les dépendances npm (workspaces), construit le paquet partagé shared, puis compile le frontend React/Vite (vite build) dans client/dist. C'est à cette étape que VITE_API_URL est figée dans le bundle (voir plus bas).
  2. server-build — installe les dépendances, construit shared, puis compile le backend TypeScript (tsc) dans server/dist. Les fichiers .sql non compilables (migrations, bootstrap.sql) sont copiés à côté du code compilé.
  3. server-deps — installe uniquement les dépendances de production (npm ci --omit=dev), avec la toolchain de compilation native (python3, make, g++) nécessaire à certains modules npm.
  4. runtime — image finale légère, basée sur node:22-bookworm-slim : elle ne récupère que le strict nécessaire des trois étapes précédentes (dépendances de prod, build serveur, build client) — aucun outil de compilation ni code source ne s'y retrouve.

Toutes les étapes partent de la même image de base node:22-bookworm-slim.

Commande de build

bash
docker build --build-arg VITE_API_URL=https://timepick.example.org/api -t timepick .

Adapter VITE_API_URL au domaine réel de destination (voir avertissement ci-dessous) et timepick au nom d'image souhaité.

VITE_API_URL est figée au build, pas au run

VITE_API_URL est un argument de build (ARG Docker), pas une variable d'environnement runtime. Vite l'intègre en dur dans le bundle JavaScript du frontend au moment de vite build (stage client-build) — elle n'est plus lisible ni modifiable une fois l'image construite.

Conséquence : une image construite avec VITE_API_URL=https://a.example.org/api ne fonctionnera pas correctement si on la démarre pour servir b.example.org — le frontend continuera d'appeler a.example.org/api, même si toutes les variables d'environnement du conteneur sont changées au docker run.

Il faut donc reconstruire l'image (docker build --build-arg VITE_API_URL=...) à chaque changement de domaine public, ou dès qu'on déploie une nouvelle instance sur un autre domaine.

En architecture mono-conteneur (le même serveur Express sert le SPA et l'API sur le même port), VITE_API_URL pointe simplement vers <origine-publique>/api — pas besoin de sous-domaine séparé pour l'API.

Chemin relatif /api : une image indépendante du domaine

Comme le même serveur Express sert le SPA et l'API, VITE_API_URL accepte aussi un chemin relatif : --build-arg VITE_API_URL=/api. Le frontend appelle alors l'API en same-origin, quel que soit le domaine — l'image devient réutilisable sur n'importe quel hôte sans reconstruction. C'est ainsi qu'est construite l'image canonique publiée par la CI du projet (voir ci-dessous).

Alternative : tirer l'image publiée (GHCR)

Le dépôt embarque un workflow CI (.github/workflows/docker.yml) qui, à chaque tag de version v*, construit et teste d'abord la variante linux/amd64 (démarrage contre un PostgreSQL de service jusqu'à ce que GET /health réponde 200), puis — seulement si ce test passe — construit et publie l'image multi-architecture (linux/amd64 + linux/arm64) sur ghcr.io/timepick-app/timepick (tags latest, <tag git> et sha-<court>). Cette image est construite avec VITE_API_URL=/api : indépendante du domaine, elle se lance telle quelle avec les commandes de cette fiche, sans étape de build.

Image publiée et publique depuis v0.29.0

L'image est publiée sur GHCR depuis la version v0.29.0 et le paquet est public : le tirage fonctionne en anonyme, sans docker login :

bash
docker pull ghcr.io/timepick-app/timepick:v0.32.1

En production, toujours épingler un tag versionné (vX.Y.Z) — jamais latest, dont le contenu change à chaque version publiée.

Le modèle complet « image unique tirée par tous les hôtes » (PaaS, Coolify en mode pull) est décrit dans Déployer sur un PaaS.

Démarrage du conteneur

Le conteneur expose un port unique : 3000 (EXPOSE 3000 dans le Dockerfile). Il n'y a rien à exposer côté client — tout passe par ce même port.

Au démarrage, le conteneur exécute :

node dist/prepare-db.js && node dist/index.js

C'est la commande définie dans le CMD du Dockerfile — pas de dépendance à une fonctionnalité « pre-deploy » spécifique à un orchestrateur.

prepare-db.js est un bootstrap idempotent, rejoué à chaque démarrage du conteneur (redéploiement compris), qui effectue dans l'ordre :

  1. Application du bootstrap de base (extension uuid-ossp, table users, fonction update_updated_at_column) si la base est vierge — sans effet si déjà en place.
  2. Application des migrations SQL en attente (mêmes migrations que npm run migrate).
  3. Provisionnement SMTP initial : si aucune configuration SMTP n'existe encore en base, les variables SMTP_* fournies à l'image sont copiées en base (mot de passe chiffré avec ENCRYPTION_KEY). Ce provisionnement est un seed « one-shot » — au redémarrage suivant, s'il existe déjà une configuration SMTP en base, les variables SMTP_* sont ignorées (la configuration s'administre ensuite dans l'application, Paramètres → Serveur d'email).

Une fois prepare-db.js terminé, node dist/index.js démarre le serveur Express, qui se met à écouter sur le port 3000.

Base de données requise avant le premier démarrage

Le conteneur ne crée pas la base PostgreSQL elle-même (nom de base, rôle, etc.) — seulement son contenu (tables, extension, migrations). Une base PostgreSQL vide, accessible via DATABASE_URL, doit exister avant le premier docker run. Voir aussi Prérequis pour la version minimale de PostgreSQL requise.

Variables d'environnement minimales

Le détail complet de chaque variable (formats, contraintes, génération des secrets) est documenté dans Variables d'environnement. Pour un premier démarrage fonctionnel, le conteneur a besoin au minimum de :

Dans le kit Docker Compose ci-dessus, JWT_SECRET et ENCRYPTION_KEY sont volontairement requis (échec rapide si absents, pour une sauvegarde réduite à .env + volumes). L'auto-génération décrite ci-dessous ne concerne que les déploiements docker run/PaaS qui ne les fournissent pas.

VariableRôle
DATABASE_URLConnexion à la base PostgreSQL externe
JWT_SECRETSignature des jetons de session — optionnel, généré automatiquement au premier démarrage si absent (voir encadré ci-dessous)
ENCRYPTION_KEYChiffrement des secrets stockés en base (mot de passe SMTP notamment) — optionnel, généré automatiquement au premier démarrage si absent (voir encadré ci-dessous)
APP_URLURL publique utilisée dans les liens envoyés par email
EMAIL_FROMAdresse d'expédition par défaut des emails
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, …Provisionnement initial du serveur d'email (premier démarrage uniquement — voir ci-dessus)

Variables optionnelles supplémentaires : le stockage objet des uploads (STORAGE_DRIVER=s3 + variables S3_*) et le fournisseur d'email HTTP (EMAIL_PROVIDER, EMAIL_API_CREDENTIALS) sont documentés dans Variables d'environnement.

Secrets optionnels

JWT_SECRET et ENCRYPTION_KEY sont optionnels. S'ils sont absents de l'environnement, ils sont générés automatiquement au premier démarrage (32 octets aléatoires, 64 caractères hexadécimaux) et écrits dans server/data/ (jwt.secret, encryption.key, permissions 0600) — voir la section « Volumes persistants » plus bas. Recommandés en variables d'environnement en production.

Précédence : variable d'environnement > fichier. Si la variable est définie et valide, elle est utilisée et le fichier généré est ignoré (avertissement loggé) — c'est la façon de « promouvoir » une clé auto-générée en variable gérée.

Si ENCRYPTION_KEY est fournie via l'environnement, elle doit faire exactement 64 caractères hexadécimaux, sinon le serveur échoue immédiatement au démarrage avec un message explicite. JWT_SECRET accepte n'importe quelle valeur non vide.

TIP

Sans SMTP_* au premier démarrage, l'application reste utilisable, mais la configuration du serveur d'email devra être saisie manuellement dans l'interface (Paramètres → Serveur d'email) avant de pouvoir envoyer le moindre email de connexion.

Volumes persistants (uploads et données)

Deux répertoires du conteneur doivent être sur un volume persistant en production : les uploads d'images, et les secrets auto-générés.

Uploads — avec le driver de stockage par défaut (STORAGE_DRIVER=local), les images uploadées dans l'éditeur d'emails (logos, illustrations) sont stockées sur le disque du conteneur, dans /app/server/uploads. Alternative sans disque : STORAGE_DRIVER=s3 externalise ces images vers un object storage S3-compatible — le volume uploads devient alors inutile (voir Variables d'environnement).

Données (secrets auto-générés) — si JWT_SECRET et/ou ENCRYPTION_KEY ne sont pas fournis en variables d'environnement, ils sont générés au premier démarrage et écrits dans /app/server/data.

Volumes obligatoires

Ces deux chemins ne sont pas persistants par défaut : ce sont des répertoires ordinaires à l'intérieur du système de fichiers du conteneur.

  • Sans volume monté sur /app/server/uploads (en stockage local, le défaut), toutes les images uploadées disparaissent au premier redéploiement ou à la première recréation du conteneur (mise à jour d'image comprise).
  • Sans volume monté sur /app/server/data, les secrets générés automatiquement sont recréés à chaque recréation du conteneur : le mot de passe SMTP stocké en base ne peut plus être déchiffré, et toutes les sessions / magic-links en attente sont invalidés — rupture silencieuse de l'envoi d'emails, avec un risque de verrouillage des administrateurs. Le recours de dernier ressort est alors les codes de récupération de connexion d'urgence.

Il faut monter un volume Docker nommé (ou un bind mount) sur chacun de ces deux chemins avant tout usage en production.

Aucun volume nécessaire avec secrets en env + stockage objet

Si JWT_SECRET et ENCRYPTION_KEY sont fournis en variables d'environnement et que STORAGE_DRIVER=s3 externalise les uploads, plus aucun volume n'est requis — c'est la configuration obligatoire sur les plateformes sans disque persistant (voir Déployer sur un PaaS).

Healthcheck applicatif

Le serveur expose un endpoint public GET /health, qui répond 200 avec un statut JSON (ok ou degraded selon l'état de la connexion SMTP). Le Dockerfile de TimePick ne définit pas d'instruction HEALTHCHECK native — c'est à l'orchestrateur (Coolify, Docker Compose, Kubernetes…) de configurer ce contrôle, en pointant vers GET /health sur le port 3000.

Exemple complet

Base PostgreSQL externe attendue — pas de PostgreSQL embarqué dans cette image. Adapter <IP_DU_SERVEUR> ou l'hôte réseau de la base selon votre environnement.

Créer d'abord les volumes persistants :

bash
docker volume create timepick-uploads
docker volume create timepick-data

Construire l'image en fixant l'URL d'API publique :

bash
docker build --build-arg VITE_API_URL=https://timepick.example.org/api -t timepick .

Lancer le conteneur avec un fichier d'environnement :

bash
docker run -d --name timepick -p 3000:3000 --env-file server/.env -v timepick-uploads:/app/server/uploads -v timepick-data:/app/server/data timepick

Alternative avec des variables individuelles plutôt qu'un fichier :

bash
docker run -d --name timepick -p 3000:3000 -e DATABASE_URL=postgresql://user:password@<IP_DU_SERVEUR>:5432/timepick -e JWT_SECRET=<secret_genere> -e ENCRYPTION_KEY=<cle_64_hex> -e APP_URL=https://timepick.example.org -e EMAIL_FROM=noreply@timepick.example.org -v timepick-uploads:/app/server/uploads -v timepick-data:/app/server/data timepick

Vérifier que le conteneur répond :

bash
curl http://localhost:3000/health

Une réponse 200 avec un statut ok (ou degraded si le SMTP n'est pas encore configuré) confirme que le serveur a démarré et que prepare-db.js s'est exécuté avec succès. Consulter ensuite Configuration initiale pour le premier assistant de configuration (création de l'administrateur).

localhost dans le conteneur

Dans le conteneur, localhost désigne le conteneur lui-même, pas la machine hôte. Si PostgreSQL tourne sur la machine hôte (hors conteneur), DATABASE_URL doit pointer vers une adresse joignable depuis l'intérieur du conteneur (IP de l'hôte, nom de service sur un réseau Docker partagé, ou équivalent selon la plateforme) — jamais localhost. Avec le kit Compose ci-dessus, ce point est déjà réglé : DATABASE_URL pointe vers le nom de service db, résolu sur le réseau interne Compose.

Publié sous licence FSL-1.1-MIT.