Déployer une application Express sur DigitalOcean
Suivez ça sur votre propre serveur. Commencer gratuitement, sans carte
Vous avez une application Express qui tourne très bien avec node server.js sur votre ordinateur, et un compte DigitalOcean. Ce qui manque, c’est tout l’entre-deux : un serveur qui reste debout une fois votre terminal fermé, une vraie base de données Postgres au lieu d’un objet en mémoire, un conteneur qui redémarre le processus en cas de plantage, et un certificat pour que l’application réponde sur https:// plutôt que sur une simple adresse IP. Rien de tout ça n’est difficile en soi, mais chaque pièce a sa propre petite façon de mal tourner.
Ce guide construit l’ensemble avec Docker Compose : un conteneur pour Postgres, un pour l’application Express, et un pour Caddy qui gère le HTTPS. L’application elle-même reste volontairement petite : deux routes, dont une branchée sur un vrai Pool pg, pour qu’il y ait une vraie connexion à une base de données à configurer, et pas seulement une réponse statique. À la fin, vous aurez un droplet qui sert l’application sur votre propre domaine, avec une façon reproductible de livrer de nouvelles versions et de sauvegarder la base de données. Ensuite on regarde le raccourci : pousser le même dépôt vers Railyard et le laisser lire package.json à votre place.
Ce qu’il vous faut
- Un droplet DigitalOcean. Le forfait Basic à 1 vCPU / 2 Go de RAM fait tourner Postgres, le processus Node et Caddy ensemble sans problème, et coûte actuellement 12 $US/mois ; le forfait 1 Go (6 $US/mois) convient pour une petite application sans beaucoup de charge simultanée, mais laisse peu de marge une fois Postgres ajouté.
- Un nom de domaine, avec accès pour ajouter un enregistrement
A. - Un dépôt GitHub (ou tout autre hébergeur Git) contenant votre projet Express.
- Un compte DigitalOcean avec la facturation configurée, et une clé SSH ajoutée à votre compte.
- Docker installé en local si vous voulez valider le fichier Compose et le Caddyfile avant de les copier sur le serveur, ce que ce guide fait.
Étape 1 : créer le droplet
Depuis le tableau de bord DigitalOcean, créez un nouveau droplet : choisissez l’image Ubuntu 24.04 LTS, le forfait Basic 2 Go, une région proche de vos utilisateurs, et votre clé SSH. Une fois qu’il tourne, notez son adresse IP publique et pointez l’enregistrement A de votre domaine vers celle-ci :
myapp.example.com. A 203.0.113.20
La propagation DNS peut prendre de quelques minutes à quelques heures. Vous pouvez continuer à configurer le serveur en attendant ; Caddy a seulement besoin que l’enregistrement DNS soit correct au moment où il tente d’émettre un certificat.
Connectez-vous en SSH en tant que root et installez Docker :
ssh root@203.0.113.20
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Vérifiez que ça a fonctionné :
docker compose version
Vous devriez voir une chaîne de version pour le plugin Compose, et non une erreur « command not found ».
Étape 2 : écrire l’application Express
Express 5.3.0 est la version npm actuelle d’Express 5, la version majeure actuelle, et pg 8.23.1 est la version npm actuelle de node-postgres, le pilote standard pour parler à Postgres depuis Node sans ORM. Initialisez le projet et installez les deux :
npm init -y
npm install express@^5.3.0 pg@^8.23.1
package.json devrait se retrouver avec ces champs, en plus de ce que npm init a rempli :
{
"name": "myapp",
"version": "1.0.0",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.3.0",
"pg": "^8.23.1"
}
}
Maintenant server.js. Cette application a deux routes : un simple contrôle de santé, et une qui interroge Postgres à travers un pool de connexions qui lit DATABASE_URL depuis l’environnement.
const express = require("express");
const { Pool } = require("pg");
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.get("/", (req, res) => {
res.json({ status: "ok" });
});
app.get("/widgets", async (req, res) => {
const result = await pool.query(
"SELECT id, name, created_at FROM widgets ORDER BY id DESC LIMIT 20"
);
res.json(result.rows);
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "internal error" });
});
const port = process.env.PORT || 3000;
app.listen(port, "0.0.0.0", () => {
console.log(`listening on ${port}`);
});
Quelques points à remarquer. app.listen se lie à 0.0.0.0, pas au localhost par défaut, ce qui compte une fois que l’application tourne dans un conteneur : un processus qui écoute seulement sur localhost à l’intérieur d’un conteneur est inaccessible depuis l’extérieur de celui-ci, même avec le port publié. L’application lit process.env.PORT, avec 3000 comme valeur de repli pour le développement local, mais la configuration du droplet plus bas fixe PORT explicitement, donc ce repli n’est jamais vraiment utilisé là.
La route /widgets n’a pas de try/catch autour de l’appel await pool.query(...), et c’est volontaire : Express 5 a changé la façon dont il gère les erreurs levées dans les gestionnaires de routes async. Dans Express 4, une promesse rejetée dans un gestionnaire async devait être attrapée et passée à next(err) manuellement, sinon la requête restait bloquée sans aucune réponse. Express 5 fait ça automatiquement : une promesse rejetée d’un gestionnaire async est transmise au middleware de gestion d’erreurs de la même façon qu’un throw synchrone le serait. C’est pourquoi le gestionnaire d’erreurs en bas de ce fichier (celui à quatre arguments) suffit à attraper une requête échouée sans entourer chaque corps de route d’un try/catch.
Il vous faudra aussi une table pour que cette requête retourne quelque chose. Une fois Postgres en marche (Étape 4 plus bas), créez-la :
docker compose exec -T db psql -U myapp -d myapp -c "CREATE TABLE widgets (id serial PRIMARY KEY, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());"
Étape 3 : écrire le Dockerfile
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
node:24-alpine est une version LTS actuelle de Node.js et une petite image de base. L’étape deps lance npm ci --omit=dev à partir du seul fichier de verrouillage, donc l’installation des dépendances est mise en cache séparément de la copie du reste du code source ; un changement dans server.js n’oblige pas npm ci à se relancer. L’étape runner copie le node_modules installé depuis deps, puis le code source de l’application, et tourne en tant que node, l’utilisateur non root déjà présent dans l’image Node officielle, plutôt qu’en root. EXPOSE 3000 documente le port sur lequel l’application écoute à l’intérieur du conteneur ; ça ne publie rien en soi, c’est Caddy et Compose qui s’en chargent à l’étape suivante.
Étape 4 : écrire docker-compose.yml
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 5
web:
build: .
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
expose:
- "3000"
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
volumes:
postgres_data:
caddy_data:
caddy_config:
Seul Caddy publie des ports vers l’extérieur ; web et db restent accessibles uniquement depuis les conteneurs du même réseau Compose, ce qui fait une chose de moins exposée à Internet par accident. web dépend de db en bonne santé, pas seulement démarré, pour que l’application n’essaie pas de se connecter avant que Postgres accepte vraiment des connexions.
J’ai validé ce fichier localement avant de l’utiliser :
docker compose -f /tmp/compose-check/docker-compose.yml config
Il s’est analysé proprement, sans aucun avertissement, une fois POSTGRES_PASSWORD défini dans l’environnement (Compose avertit, mais résout quand même, quand une variable interpolée comme ${POSTGRES_PASSWORD} n’est pas définie au moment du config ; sur le droplet, elle vient de .env, configuré à l’Étape 6).
Étape 5 : écrire le Caddyfile
myapp.example.com {
reverse_proxy web:3000
}
Caddy demande et renouvelle automatiquement un certificat Let’s Encrypt pour myapp.example.com dès son premier démarrage, du moment que le DNS du domaine pointe déjà vers ce serveur et que les ports 80 et 443 l’atteignent. Tout est redirigé vers le processus Express sur le port 3000, le même port que le Dockerfile expose et sur lequel l’application écoute.
J’ai validé ce Caddyfile avec le validateur de Caddy lui-même avant de le copier sur le droplet :
docker run --rm -v /tmp/compose-check/Caddyfile:/etc/caddy/Caddyfile caddy:2 caddy validate --config /etc/caddy/Caddyfile
Il a affiché « Valid configuration » sans aucune erreur.
Étape 6 : configurer vos variables d’environnement et secrets
Créez un fichier .env à côté de docker-compose.yml, gardez-le hors du contrôle de version, et verrouillez ses permissions :
POSTGRES_DB=myapp
POSTGRES_USER=myapp
POSTGRES_PASSWORD=un-mot-de-passe-long-et-aleatoire
DATABASE_URL=postgres://myapp:un-mot-de-passe-long-et-aleatoire@db:5432/myapp
PORT=3000
Générez le mot de passe avec openssl rand -base64 32 plutôt que de taper quelque chose de mémorable, et fixez les permissions du fichier pour que seul root puisse le lire :
chmod 600 .env
Remarquez que DATABASE_URL pointe vers db, le nom du service depuis docker-compose.yml, pas localhost. Les conteneurs sur le même réseau Compose se résolvent entre eux par nom de service ; localhost à l’intérieur du conteneur web désigne le conteneur web lui-même, qui n’a pas Postgres dedans.
Étape 7 : ouvrir le pare-feu et déployer
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable
Puis, depuis le répertoire contenant votre docker-compose.yml :
docker compose up -d --build
docker compose logs -f caddy
Vous devriez voir Caddy signaler qu’il a obtenu un certificat pour votre domaine, puis docker compose logs -f web devrait montrer listening on 3000 sans erreur de connexion. Visitez https://myapp.example.com dans un navigateur : vous devriez voir {"status":"ok"} depuis la route racine, et https://myapp.example.com/widgets devrait retourner un tableau vide (ou les lignes insérées à l’Étape 2) plutôt qu’une erreur 500.
Étape 8 : sauvegardes et mises à jour
Sauvegardez Postgres avec un dump planifié, écrit en dehors du conteneur pour qu’un volume perdu n’emporte pas la sauvegarde avec lui :
docker compose exec -T db pg_dump -U myapp myapp > backup-$(date +%F).sql
Mettez ça dans une tâche cron et copiez le résultat ailleurs que sur le droplet (DigitalOcean Spaces, S3, ou simplement scp vers une autre machine). Pour livrer une nouvelle version, récupérez votre dernier code, reconstruisez, et relancez le conteneur web ; Postgres et Caddy n’ont pas besoin de redémarrer :
git pull
docker compose up -d --build web
Le raccourci : avec Railyard
Tout ce qui précède (le Dockerfile, l’utilisateur non root, DATABASE_URL, le proxy Caddy et le certificat) est ce que Railyard configure automatiquement quand vous le pointez vers le même dépôt. D’après ses propres règles de détection : le gestionnaire de paquets est choisi à partir du fichier de verrouillage présent dans le dépôt (package-lock.json, pnpm-lock.yaml, yarn.lock, ou bun.lockb), et le script start de package.json tourne comme processus web une fois le build terminé ; si le dépôt fournit son propre Dockerfile, c’est celui-là qui est construit et lancé directement. Le conteneur écoute sur le $PORT que Railyard attribue, la même variable d’environnement que le server.js de ce guide lit déjà. Un script npm run worker, s’il existe dans package.json, est proposé comme processus worker en arrière-plan à côté du processus web. Une dépendance pg dans package.json suffit pour que Railyard provisionne automatiquement une base Postgres gérée, avec DATABASE_URL injecté comme variable d’environnement, sans addon à ajouter à la main et sans Dockerfile requis pour un dépôt qui n’en fournit pas. Voir la documentation Node.js pour les règles exactes de détection.
Pour déployer de cette façon : créez un droplet ou un serveur depuis Railyard (DigitalOcean est l’un des fournisseurs pris en charge), puis collez l’URL Git de votre dépôt, ou ajoutez ceci à votre README pour un déploiement en un clic :
[](https://app.railyard.run/deploy?repo=https://github.com/you/your-express-app)
Le forfait gratuit de Railyard couvre un serveur plus un serveur de base de données et trois applications sans carte requise. Pro est à 19 $/mois par équipe, et Team est à 49 $/mois par équipe pour des limites plus élevées et plus de collaborateurs.
Le chemin manuel ci-dessus reste celui qu’il faut connaître. C’est ce que Railyard fait sous le capot, et le comprendre veut dire que vous ne serez jamais bloqué si un changement n’est pas encore exposé dans le tableau de bord.
Dépannage
« Port is already allocated » en lançant docker compose up. Quelque chose d’autre est déjà lié au port 80 ou 443, souvent un nginx par défaut installé par une image DigitalOcean en un clic. Arrêtez-le et désactivez-le : systemctl stop nginx && systemctl disable nginx, puis réessayez.
Connexion Postgres refusée, ou ECONNREFUSED dans les logs de l’application. Vérifiez DATABASE_URL dans .env : il doit pointer vers db, le nom du service Compose, pas localhost. localhost à l’intérieur du conteneur web désigne le conteneur web, qui n’a pas Postgres dedans.
Permission refusée pour écrire un fichier, ou le conteneur se ferme tout de suite avec une erreur EACCES. Le conteneur tourne en tant qu’utilisateur non root node depuis l’Étape 3. Si votre application écrit dans un répertoire qu’elle ne possède pas (un fichier de log, un dossier d’upload intégré dans l’image), créez ce répertoire avec les bonnes permissions dans le Dockerfile avant de passer à USER node, ou écrivez plutôt dans un volume monté.
Le HTTPS ne se met jamais en place, ou Caddy affiche des erreurs de certificat. Vérifiez que l’enregistrement A du domaine pointe bien vers le droplet (dig myapp.example.com) et que les ports 80 et 443 sont ouverts à la fois dans le pare-feu du droplet et dans le pare-feu cloud de DigitalOcean si vous en avez ajouté un. Let’s Encrypt valide d’abord en HTTP simple, donc le port 80 doit être accessible même si le site final n’est qu’en HTTPS.
L’application se fait tuer ou le droplet ralentit sous un vrai trafic. Sur un droplet de 1 Go, Postgres, le processus Node et Caddy se disputent tous la même mémoire, et un processus Node sous charge simultanée peut faire grossir son tas rapidement. Surveillez docker stats sous charge, ajoutez un fichier swap comme filet de sécurité, ou passez au droplet de 2 Go si le trafic est réel.
FAQ
Ai-je besoin de Docker, ou puis-je faire ça avec un gestionnaire de processus et une installation Node au niveau du système ? Docker Compose garde Postgres, l’application et Caddy isolés et faciles à déplacer entre serveurs, mais pm2 ou systemd lançant node server.js contre un Postgres installé au niveau du système fonctionnent de la même façon ; l’appel app.listen et la configuration Caddy ci-dessus ne changent pas dans un cas comme dans l’autre.
Qu’est-ce qui a vraiment changé entre Express 4 et 5 qui compte ici ? Le changement pertinent pour ce guide est le transfert automatique des promesses rejetées des gestionnaires de routes async vers le middleware de gestion d’erreurs, ce qui explique pourquoi /widgets ci-dessus n’a pas de try/catch autour de son await. Express 5 a aussi retiré quelques API dépréciées depuis longtemps et relevé la version minimale de Node.js prise en charge, mais ce guide ne repose sur rien d’autre qui ait changé.
Puis-je utiliser un ORM comme Prisma ou Sequelize au lieu de pg brut ? Oui. L’un ou l’autre lit DATABASE_URL de la même façon que pg.Pool ici, et le Dockerfile, le fichier Compose et le Caddyfile ne changent pas ; seuls server.js et la dépendance dans package.json changent. Ce guide utilise pg brut pour garder l’exemple petit et éviter une étape de build pour un fichier de schéma.
Comment ajouter un worker en arrière-plan ? Ajoutez un service worker à docker-compose.yml, construit depuis la même image et le même fichier .env, avec une command qui lance autre chose que server.js, par exemple node worker.js. Il peut utiliser le même pg.Pool et DATABASE_URL que le processus web, ou ajouter Redis comme autre conteneur si la file de tâches en a besoin.
Le droplet de 1 Go suffit-il vraiment ? Pour une petite application sans beaucoup de trafic simultané, oui, le palier à 6 $/mois fonctionne bien. Une fois que Postgres, le processus Node et Caddy tournent tous ensemble sous une vraie charge, le forfait 2 Go à 12 $/mois donne assez de marge pour qu’un pic de trafic ne se transforme pas en arrêt pour manque de mémoire.
Une application Express sur un droplet n’a rien de fragile une fois les pièces correctement assemblées, et la plupart de ce qui est ici n’est pas spécifique à Express : le même conteneur Postgres, le même proxy Caddy et la même tâche cron de sauvegarde fonctionnent aussi pour la prochaine application Node que vous mettrez sur ce serveur.
Pourquoi les gens choisissent Railyard pour ça
- Votre propre compte infonuagique. Déployez sur DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou n'importe quel serveur Linux par SSH, facturé directement par votre fournisseur, sans majoration.
- Un prix fixe par équipe. L'offre gratuite est gratuite pour toujours : un serveur plus un serveur de base de données, trois applications, sans carte. Pro coûte 19 $ par mois pour jusqu'à cinq serveurs et un nombre illimité d'applications. Team coûte 49 $ par mois avec des serveurs illimités. Aucuns frais par application, par déploiement, par siège ou par gigaoctet.
- Des gabarits en un clic. Déployez des applications open source, comme des bases de données, des outils d'analyse et des outils de développement, sur votre propre serveur en un clic.
- Des bases de données gérées. Postgres, MySQL et Redis, avec des sauvegardes planifiées, vérifiées par restauration, et la récupération à un point dans le temps.
- Déploiement par push. Déployez depuis GitHub, GitLab ou la CLI, avec HTTPS automatique et des mises en production sans interruption.