Comment déployer un backend Node.js avec Postgres
Suivez ça sur votre propre serveur. Commencer gratuitement, sans carte
« Déployer un backend Node avec Postgres » ressemble à une seule tâche, mais c’en est vraiment quatre : écrire l’API, lui donner une vraie base de données plutôt qu’un tableau en mémoire, changer la forme de cette base en toute sécurité à mesure que l’application grandit, et s’assurer que les connexions entre les deux ne s’effondrent pas dès que deux requêtes arrivent en même temps. La plupart des tutoriels Node-et-Postgres s’arrêtent aux deux premières. Celui-ci construit les quatre : une API Express branchée sur pg, des migrations qui tournent de la même façon dans chaque environnement, un pool de connexions dimensionné exprès plutôt que par accident, et une configuration Docker Compose pour le développement local qui correspond à ce qui tourne vraiment en production.
Ce qu’il vous faut
- Node.js 22 ou 24 installé en local.
- Docker et le plugin Docker Compose, pour le développement local et pour valider la configuration avant qu’elle n’aille ailleurs.
- Une aisance de base avec SQL ; les migrations ci-dessous sont de simples fichiers SQL, pas un schéma généré par un ORM.
Étape 1 : écrire l’API
Express 5.3.0 est la version actuelle, et pg 8.23.1 est la version actuelle de node-postgres, le pilote standard pour parler à Postgres depuis Node sans ORM :
npm init -y
npm install express@^5.3.0 pg@^8.23.1
npm install --save-dev node-pg-migrate@^9.0.0 nodemon@^3.1.0
server.js :
const express = require("express");
const { Pool } = require("pg");
const app = express();
app.use(express.json());
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: Number(process.env.PG_POOL_MAX || 10),
idleTimeoutMillis: 30000,
});
app.get("/health", (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.post("/widgets", async (req, res) => {
const { name } = req.body;
if (!name) return res.status(400).json({ error: "name is required" });
const result = await pool.query(
"INSERT INTO widgets (name) VALUES ($1) RETURNING id, name, created_at",
[name]
);
res.status(201).json(result.rows[0]);
});
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}`));
Comme pour toute application Express 5, les gestionnaires de routes async ci-dessus n’ont pas besoin d’un try/catch autour de leurs appels à la base de données : Express 5 transmet automatiquement une promesse rejetée d’un gestionnaire async au middleware de gestion d’erreurs, de la même façon qu’un throw synchrone le ferait, donc le gestionnaire à quatre arguments en bas attrape une requête échouée sans entourer chaque corps de route.
Étape 2 : écrire de vraies migrations, pas un schéma qu’on lance une fois à la main
Taper CREATE TABLE directement dans psql fonctionne exactement une fois, la première. La deuxième fois qu’il vous faut une nouvelle colonne, soit vous modifiez cette même commande de mémoire, soit vous avez perdu le fil de ce qui a réellement été appliqué où. node-pg-migrate règle ça en gardant chaque changement comme un fichier numéroté et horodaté qui s’exécute dans l’ordre, avec un registre de ce qui a déjà été appliqué, conservé dans la base elle-même.
Créez migrations/1700000000000_create-widgets.js :
exports.up = (pgm) => {
pgm.createTable("widgets", {
id: "id",
name: { type: "text", notNull: true },
created_at: { type: "timestamptz", notNull: true, default: pgm.func("now()") },
});
};
exports.down = (pgm) => {
pgm.dropTable("widgets");
};
Ajoutez des scripts à package.json :
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"migrate:up": "node-pg-migrate up",
"migrate:down": "node-pg-migrate down"
}
}
node-pg-migrate lit sa chaîne de connexion depuis la variable d’environnement DATABASE_URL par défaut, la même variable que l’application elle-même utilise, donc il n’y a qu’un seul endroit où l’adresse de la base est configurée. Lancez-le une fois Postgres démarré (Étape 4) :
DATABASE_URL=postgres://myapp:monmotdepasse@localhost:5432/myapp npm run migrate:up
Vous devriez voir widgets listé comme appliqué, et \dt dans psql devrait montrer la table. L’intérêt d’un fichier de migration par rapport à un CREATE TABLE ponctuel n’est pas la forme : c’est que les mêmes fichiers exacts s’exécutent en développement, en CI et en production, donc « est-ce que le schéma a vraiment été appliqué en production » est une question avec une réponse basée sur des fichiers plutôt que sur la mémoire.
Étape 3 : dimensionner le pool de connexions exprès
new Pool({ connectionString, max: 10 }) signifie que ce seul processus Node ouvrira au plus 10 connexions simultanées vers Postgres, les réutilisant d’une requête à l’autre plutôt que d’ouvrir une nouvelle connexion TCP par requête SQL. Ce nombre n’est pas arbitraire, et se tromper dans un sens ou dans l’autre cause un vrai problème :
- Trop bas, et les requêtes font la queue derrière le pool en attendant une connexion libre sous une vraie concurrence, ce qui se manifeste par une latence qui n’a rien à voir avec la vitesse des requêtes SQL.
- Trop haut, et ça cesse d’être une décision par processus : Postgres lui-même a un réglage
max_connections, 100 par défaut, partagé entre tous les clients connectés à cette base. Lancez trois instances de cette application avecmax: 50chacune, et vous avez réservé 150 connexions avant même qu’un autre service, un lancement de migration, ou une sessionpsqld’admin n’essaie de se connecter, et Postgres se met à refuser carrément les nouvelles connexions plutôt que de se dégrader en douceur.
Un point de départ raisonnable pour une petite application est 10 à 20 connexions par processus, multiplié par le nombre d’instances de l’application réellement en marche, vérifié contre le max_connections réellement configuré sur la base plutôt que supposé. idleTimeoutMillis: 30000 ci-dessus ferme les connexions que le pool n’utilise pas depuis 30 secondes, pour qu’une période calme ne garde pas des connexions ouvertes pour rien.
Étape 4 : Docker Compose pour le développement local
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: devpassword
ports:
- "5432:5432"
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
environment:
DATABASE_URL: postgres://myapp:devpassword@db:5432/myapp
PORT: 3000
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
command: npm run dev
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
Publier le port de db en local (contrairement à un fichier compose de production) est volontaire ici : ça permet de connecter un client graphique ou de lancer psql depuis votre propre machine contre la même base que celle utilisée par l’application, ce qui vaut la peine pour la commodité pendant le développement. Le service web monte le répertoire du projet par-dessus l’image et lance npm run dev (nodemon) au lieu de npm start, pour que modifier server.js redémarre le processus sans reconstruire l’image ; /app/node_modules comme volume anonyme à part empêche le montage lié de masquer le node_modules déjà installé dans l’image par le Dockerfile.
Démarrez tout et lancez la migration contre la base conteneurisée :
docker compose up -d
docker compose exec web npm run migrate:up
Ensuite curl -X POST localhost:3000/widgets -H "Content-Type: application/json" -d '{"name":"first widget"}' suivi de curl localhost:3000/widgets devrait retourner la ligne que vous venez d’insérer.
Étape 5 : le Dockerfile pour la production
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"]
J’ai validé la forme compose de production (ce Dockerfile plus un service db et web sans les montages de développement ni le port Postgres publié) avec :
docker compose -f /tmp/compose-check/docker-compose.yml config
Ça s’est analysé proprement avec POSTGRES_PASSWORD et DATABASE_URL définis dans l’environnement.
Le chemin de déploiement
Un VPS faisant tourner tout ça avec Docker Compose, un pg_dump planifié par cron, et Caddy devant pour le HTTPS fonctionne et c’est un chemin bien balisé ; voir déployer une application Express sur DigitalOcean pour ce parcours complet, y compris le Caddyfile et les règles de pare-feu.
Le chemin plus rapide consiste à pointer Railyard vers le même dépôt. D’après ses propres règles de détection : une dépendance pg dans package.json suffit pour que Railyard provisionne automatiquement une base Postgres gérée et injecte DATABASE_URL, sans addon à ajouter à la main ; le script start devient le processus web, lisant $PORT, que Railyard attribue de la même façon que le server.js de ce guide l’attend déjà ; et une commande de release (réglez-la sur npm run migrate:up dans les paramètres de déploiement de l’application) s’exécute une fois avant chaque nouvelle mise en production, pour que la migration de l’Étape 2 se produise automatiquement à chaque déploiement plutôt que d’être une étape qu’il faut se rappeler de lancer en SSH. Voir la documentation Node.js pour les règles exactes de détection et variables et secrets pour le fonctionnement de DATABASE_URL et des autres variables injectées.
Pour le dimensionnement du pool de l’Étape 3, la console de base de données de Railyard a un onglet Insights qui montre en direct les connexions par rapport à max_connections, donc au lieu de deviner si max: 10 par instance est sûr, vous pouvez surveiller le nombre réel par rapport au vrai plafond à mesure que le trafic grandit ; voir bases de données et sauvegardes pour les sauvegardes, la restauration à un instant précis et la console.
Dépannage
ECONNREFUSED en se connectant à Postgres. Dans Docker Compose, DATABASE_URL doit pointer vers db, le nom du service, pas localhost ; localhost à l’intérieur du conteneur web désigne le conteneur web lui-même, qui n’a pas Postgres dedans.
Les migrations disent « déjà appliquée » mais la colonne n’est pas là. Ça signifie généralement qu’un fichier de migration a été modifié après avoir déjà été exécuté une fois. Les outils de migration suivent ce qui a été appliqué par nom de fichier/horodatage, pas en comparant le schéma réel, donc un changement dans un fichier déjà appliqué lui est invisible ; ajoutez une nouvelle migration plutôt que de modifier une ancienne, même pour corriger une erreur.
L’application fonctionne bien en local mais Postgres se met à refuser les connexions en production. C’est presque toujours le problème de dimensionnement du pool de l’Étape 3 : plus d’instances, ou un max plus élevé par instance, que ce que max_connections permet réellement. Vérifiez les connexions actuelles par rapport à la limite (l’onglet Insights, ou SELECT count(*) FROM pg_stat_activity; comparé à SHOW max_connections; à la main) avant de passer à un forfait de base de données plus gros.
Les requêtes s’accumulent sous charge même si le CPU semble correct. C’est un pool trop petit pour la concurrence que vous voyez réellement, pas nécessairement une requête lente ; pool.waitingCount (une propriété de l’instance Pool) indique combien de requêtes attendent actuellement une connexion libre, le nombre à surveiller avant de supposer que la base de données elle-même est le goulot d’étranglement.
FAQ
Ai-je besoin de Docker pour le développement local, ou puis-je simplement faire tourner Postgres nativement ? Docker Compose garde la version et la configuration de Postgres cohérentes entre votre machine et la production, et c’est ce que ce guide utilise, mais un Postgres installé nativement avec DATABASE_URL pointant vers localhost fonctionne de façon identique ; rien dans server.js ni dans les migrations ne se soucie de la façon dont Postgres tourne.
Puis-je utiliser un ORM comme Prisma ou Drizzle au lieu de pg et node-pg-migrate bruts ? Oui, et les calculs de pool de connexions et de max_connections de l’Étape 3 s’appliquent exactement de la même façon, puisque la plupart des ORM Node enveloppent pg.Pool (ou un équivalent proche) en dessous plutôt que de le remplacer. Prisma et Drizzle ont tous les deux leur propre outillage de migration, une alternative à node-pg-migrate si vous préférez garder le schéma et la couche de requêtes dans un seul outil.
Quel est un max_connections et une taille de pool sûrs pour une petite application ? Postgres a 100 par défaut. Pour une seule instance d’application avec un pool modeste de 10 à 20, ça laisse beaucoup de marge ; le nombre à vraiment surveiller, c’est le total des connexions entre toutes les instances, les lancements de migration et les sessions admin combinés, par rapport à ce qui est réellement configuré sur la base, pas le défaut pris isolément.
Pourquoi une commande de release plutôt que de lancer les migrations dans server.js au démarrage ? Lancer les migrations dans le code de démarrage du serveur veut dire que chaque réplique qui démarre essaie de les exécuter, ce qui est raisonnable pour une petite application mais devient risqué une fois qu’on a plus d’une instance web qui démarre en même temps. Une commande de release s’exécute exactement une fois, avant que le trafic ne soit routé vers la nouvelle version, ce qui est le choix plus sûr dès qu’une application a plus d’une instance.
La base de données est la partie de « déployer un backend Node avec Postgres » qu’il est facile de faire fonctionner et facile de mal faire d’une façon qui ne se voit pas avant l’arrivée d’un vrai trafic. Des migrations qui sont des fichiers plutôt que de la mémoire, et une taille de pool qui est une décision plutôt qu’un défaut, sont les deux changements qui font la différence.
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.