Stephen Build ← Roadmap
0/0
Roadmap backend · Étape 7 sur 7

En ligne, avec une vraie base

Ton API marche, mais seulement sur ton ordi. Dans ce tuto, tu ranges tes secrets dans un .env, tu passes ta base sur Postgres chez Neon, et tu mets ton serveur en ligne sur Render. À la fin, ta to-do avec comptes tourne sur une vraie adresse, et elle ne perd plus rien.

Durée 1 semaine Niveau étapes 1 à 6 du backend Projet ton backend en ligne, avec un vrai usage Mis à jour

À la fin, tu sauras

Les missions

Fais-les dans l'ordre, sur ton projet api-taches de l'étape 6. Les missions 3 et 4 sont les plus longues : à la fin de la 4, tout marche en local sur la base en ligne. Le reste, c'est la mise en ligne.

Mission 0

À l'étape 5 du frontend, Vercel a mis ton site en ligne en servant des fichiers : HTML, CSS, JS. Un fichier, ça ne réfléchit pas. Ton api-taches, lui, est un programme qui tourne en continu : il attend des requêtes, vérifie des cookies, écrit dans une base. Il lui faut une machine qui fait tourner npm start pour de bon.

Et la base ? Sur ton ordi, data.db est un fichier sur ton disque. Chez un hébergeur gratuit comme Render, le disque du serveur est effacé à chaque redéploiement, redémarrage ou mise en veille. Ton fichier SQLite repartirait de zéro, avec tous les comptes de tes utilisateurs. La solution : une base hébergée à part, qui vit sur son propre serveur. On prend Postgres, la base la plus utilisée dans le monde du web, chez Neon.

Voilà ce qui va changer dans ton projet :

api-taches
api-taches/
├── package.json
├── server.js              # Express : json, cookies, static, routers, erreurs
├── db.js                  # SQLite (node:sqlite)        → Postgres (pg)
├── auth.js                # hashPassword, verifyPassword : ne bouge pas
├── middleware/
│   └── requireAuth.js     # devient async
├── routes/
│   ├── auth.js            # devient async
│   └── tasks.js           # devient async
├── public/                # la to-do et login.html : ne bougent pas
├── data.db                # va disparaître
└── .gitignore
  • Render fait tourner ton serveur Express. Gratuit, mais il s'endort après 15 minutes sans visite.
  • Neon héberge ta base Postgres. Gratuit aussi, et elle survit à tout ce qui arrive au serveur.
  • Le front (public/) et le JSON de l'API ne changent pas : seule la persistance change, comme à l'étape 4.
  • Postgres parle SQL, comme SQLite. Tu vas retrouver SELECT, INSERT, WHERE... avec quelques différences qu'on verra.
Erreur fréquente

Croire que ça marche parce que ça a marché une fois. Avec SQLite sur Render, tu crées un compte, tout va bien... et le lendemain, après une mise en veille, il a disparu. Le piège, c'est que rien ne plante : les données s'évaporent en silence.

Demande à l'IA
Explique-moi la différence entre un hébergement statique (comme Vercel pour un site HTML) et un serveur qui tourne en continu (comme Express sur Render). Pourquoi un fichier SQLite ne survit pas sur un hébergeur gratuit dont le disque est éphémère ?
Résultat attendu
Schéma
Sur ton ordi                          En ligne
────────────                          ────────
navigateur                            navigateur (ton téléphone, tes amis)
    │ http://localhost:3000               │ https://api-taches.onrender.com
    ▼                                     ▼
Express (npm run dev)                 Express sur Render (npm start)
    │                                     │
    └──────────────┬──────────────────────┘
                   ▼
        Postgres sur Neon (DATABASE_URL)

Tu sais dire avec tes mots pourquoi data.db ne survivrait pas sur un hébergeur gratuit, et pourquoi ton ordi et le serveur en ligne parleront à la même base.

Mission 1

Ton serveur va bientôt avoir besoin d'une adresse de base de données, avec un mot de passe dedans. Pas question de l'écrire dans le code : il finirait sur GitHub. On range ces réglages dans des variables d'environnement, que ton code lit avec process.env. Tu le fais déjà : process.env.PORT || 3000.

Sur ton ordi, on les écrit dans un fichier .env à la racine du projet. Commence par l'ignorer, avant de le créer :

.gitignore
node_modules/
data.db
.env
.env
# Les réglages de TON ordi. Ce fichier ne part jamais sur GitHub.
PORT=3000

Node sait lire ce fichier tout seul. Dans package.json, remplace le bloc "scripts" (le reste ne bouge pas) :

package.json
"scripts": {
  "dev": "node --watch --env-file-if-exists=.env server.js",
  "start": "node --env-file-if-exists=.env server.js"
},
  • Une ligne par variable : NOM=valeur, sans espaces autour du =, sans guillemets.
  • --env-file-if-exists=.env : Node charge le fichier dans process.env au démarrage. « if-exists » : s'il n'y a pas de .env, il continue sans râler. Indispensable en ligne, où il n'y en aura pas.
  • Le .env ne contient que les réglages de ton ordi. En ligne, tu rentreras les mêmes variables dans l'interface de Render.
  • Après une modification du .env, coupe le serveur (Ctrl+C) et relance npm run dev.

Pour que tu (ou quelqu'un d'autre) saches quelles variables remplir, crée aussi un .env.example, sans les vraies valeurs. Lui, tu le commites :

.env.example
# Copie ce fichier en .env et remplis les valeurs
PORT=3000
DATABASE_URL=

Pour vérifier que tout marche, mets PORT=4000 dans .env, relance, puis remets 3000.

Erreur fréquente

Le .env est déjà parti sur GitHub. L'ajouter au .gitignore ne suffit pas : git rm --cached .env, puis commit. Et surtout : tout secret qui a été poussé est grillé, même supprimé ensuite, car il reste dans l'historique. Change-le (chez Neon : « Reset password »).

Résultat attendu
Terminal
$ npm run dev

> api-taches@1.0.0 dev
> node --watch --env-file-if-exists=.env server.js

Serveur lancé sur http://localhost:4000

$ git check-ignore -v .env
.gitignore:3:.env	.env

Avec PORT=4000 dans le .env, le serveur démarre sur 4000 : il lit bien le fichier. Remets 3000 ensuite. git check-ignore confirme que Git ignore .env, et git status ne le montre jamais.

Mission 2

Neon héberge des bases Postgres, avec un plan gratuit sans carte bancaire. Chaque projet a 1 Go de stockage et la base s'endort après 5 minutes sans requête (elle se réveille toute seule, en moins d'une seconde). Largement assez pour toi.

  1. Inscris-toi sur neon.com, avec ton compte GitHub, c'est le plus simple.
  2. Crée un projet : nom api-taches, version de Postgres par défaut, région AWS Europe Central 1 (Frankfurt). Mets la même région pour ton serveur ensuite : base et serveur proches, requêtes rapides.
  3. Sur le tableau de bord du projet, clique sur Connect. Neon affiche ta « connection string » : l'adresse complète de ta base, mot de passe compris. Copie-la.
  4. Colle-la dans ton .env, et remplace sslmode=require par sslmode=verify-full :
.env
PORT=3000
DATABASE_URL=postgresql://neondb_owner:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.eu-central-1.aws.neon.tech/neondb?sslmode=verify-full&channel_binding=require
  • postgresql://utilisateur:motdepasse@serveur/base : tout ce qu'il faut pour se connecter, en une ligne. C'est un secret.
  • -pooler dans le nom du serveur : Neon partage les connexions entre les requêtes. Garde-le, c'est le réglage par défaut.
  • sslmode=verify-full : la connexion est chiffrée, et ton code vérifie qu'il parle bien au vrai serveur de Neon. C'est ce que recommande Neon.

Installe pg (node-postgres), le paquet qui permet à Node de parler à Postgres, et teste la connexion avec un petit fichier jetable :

test-db.js
import pg from "pg";

const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();

const { rows } = await client.query("SELECT now() AS heure, version()");
console.log(rows[0]);

await client.end();
Terminal
$ npm install pg
$ node --env-file=.env test-db.js
Erreur fréquente

Un gros « SECURITY WARNING: The SSL modes 'prefer', 'require', and 'verify-ca' are treated as aliases for 'verify-full' » ? T'as gardé sslmode=require. Ça marche quand même, mais remplace par verify-full pour faire taire l'avertissement. Et si t'as « password authentication failed » : t'as copié l'onglet psql au lieu de la connection string seule, ou un bout du mot de passe manque (clique sur l'œil pour l'afficher avant de copier).

Résultat attendu
Terminal
$ npm install pg

added 14 packages, and audited 99 packages in 2s

$ node --env-file=.env test-db.js
{
  heure: 2026-10-08T09:12:44.318Z,
  version: 'PostgreSQL 17.5 on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit'
}

L'heure vient du serveur de Neon, en Allemagne : ton ordi vient de parler à une vraie base en ligne. Le premier appel peut prendre une seconde, le temps que la base se réveille. Une fois que ça marche, supprime test-db.js.

Mission 3

On remplace SQLite par Postgres. Commence par db.js, que tu réécris entièrement :

db.js
import pg from "pg";

const { Pool } = pg;

if (!process.env.DATABASE_URL) {
  throw new Error("DATABASE_URL manquante : vérifie ton fichier .env");
}

export const db = new Pool({
  connectionString: process.env.DATABASE_URL,
});

db.on("error", (err) => {
  console.error("Connexion Postgres perdue :", err.message);
});
  • Pool : une réserve de connexions à Neon. Chaque requête en emprunte une puis la rend. C'est plus rapide que de se reconnecter à chaque fois.
  • On garde le nom db : les routes l'importent déjà sous ce nom.
  • Le throw au démarrage : si la variable manque, tu as un message clair tout de suite, au lieu d'une erreur bizarre à la première requête.
  • db.on("error", ...) : si Neon coupe une connexion qui dormait, on l'écrit dans les logs au lieu de faire planter tout le serveur.

Avec SQLite, db.js créait les tables à chaque démarrage. Avec Postgres, on range le schéma dans son propre fichier :

schema.sql
CREATE TABLE IF NOT EXISTS users (
  id SERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS sessions (
  token TEXT PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at TIMESTAMPTZ NOT NULL
);

CREATE TABLE IF NOT EXISTS tasks (
  id SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  text TEXT NOT NULL,
  done BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
  • SERIAL : un entier qui s'incrémente tout seul, l'équivalent de AUTOINCREMENT.
  • BOOLEAN : Postgres a de vrais booléens. Fini les 0 et 1, fini toTask(row).
  • TIMESTAMPTZ : une date avec son fuseau horaire. now() donne l'heure actuelle.
  • ON DELETE CASCADE : si un compte est supprimé, ses tâches et ses sessions partent avec lui.

Un petit script lit ce fichier et l'envoie à la base :

init-db.js
import { readFileSync } from "node:fs";
import { db } from "./db.js";

const sql = readFileSync("schema.sql", "utf8");
await db.query(sql);
console.log("Tables prêtes : users, sessions, tasks");

await db.end();

Ajoute-le aux scripts de package.json, puis lance-le :

package.json
"scripts": {
  "dev": "node --watch --env-file-if-exists=.env server.js",
  "start": "node --env-file-if-exists=.env server.js",
  "db:init": "node --env-file-if-exists=.env init-db.js"
},
Terminal
$ npm run db:init
  • await directement dans le fichier, sans fonction async : ça marche dans un module ("type": "module").
  • db.end() ferme les connexions. Sans ça, le script ne s'arrêterait jamais.
  • Tu peux supprimer data.db : ta base est maintenant chez Neon. Ta nouvelle base est vide, tu recréeras ton compte.
Erreur fréquente

« relation "tasks" does not exist » au premier appel : t'as oublié npm run db:init. Et si tu modifies schema.sql plus tard, relancer le script n'ajoute pas les colonnes aux tables existantes : IF NOT EXISTS voit que la table est là et ne fait rien. Pour changer une table qui contient des données, on écrit un ALTER TABLE : c'est ce qu'on appelle une « migration ».

Résultat attendu
Terminal
$ npm run db:init

> api-taches@1.0.0 db:init
> node --env-file-if-exists=.env init-db.js

Tables prêtes : users, sessions, tasks

$ npm run db:init
...
Tables prêtes : users, sessions, tasks

Lancé deux fois, aucune erreur : IF NOT EXISTS ne recrée pas ce qui existe. Dans la console Neon, menu Tables : users, sessions et tasks, vides.

Mission 4

Avec node:sqlite, une requête répondait tout de suite. Avec Postgres, elle part sur le réseau jusqu'à Neon : elle renvoie une promesse, comme fetch. Toutes les fonctions qui touchent la base deviennent donc async, avec des await. Trois fichiers à réécrire. auth.js (le hachage) ne bouge pas.

middleware/requireAuth.js
import { db } from "../db.js";

export async function requireAuth(req, res, next) {
  const token = req.cookies.session;
  if (typeof token !== "string") {
    return res.status(401).json({ error: "Connecte-toi d'abord" });
  }

  const { rows } = await db.query(
    `SELECT users.id, users.email
     FROM sessions
     JOIN users ON users.id = sessions.user_id
     WHERE sessions.token = $1 AND sessions.expires_at > now()`,
    [token]
  );

  if (rows.length === 0) {
    return res.status(401).json({ error: "Session expirée, reconnecte-toi" });
  }

  req.user = rows[0];
  next();
}
  • db.query(sql, valeurs) remplace db.prepare(sql).get(...). Les valeurs vont dans un tableau.
  • $1, $2... remplacent les ?. Même protection contre l'injection SQL : jamais de valeur collée dans le texte de la requête.
  • Le résultat est un objet : les lignes sont dans rows, toujours un tableau. Une ligne trouvée : rows[0].
  • expires_at > now() : Postgres compare les dates lui-même. Plus besoin de Date.now().
routes/auth.js
import { Router } from "express";
import { randomBytes } from "node:crypto";
import { db } from "../db.js";
import { hashPassword, verifyPassword } from "../auth.js";
import { requireAuth } from "../middleware/requireAuth.js";

const router = Router();

const SESSION_DURATION = 30 * 24 * 60 * 60 * 1000; // 30 jours, en millisecondes
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const cookieOptions = {
  httpOnly: true,
  sameSite: "lax",
  secure: process.env.NODE_ENV === "production",
};

// Crée une session en base et pose le cookie dans la réponse
async function startSession(res, userId) {
  // Au passage, on fait le ménage dans les sessions expirées
  await db.query("DELETE FROM sessions WHERE expires_at <= now()");

  const token = randomBytes(32).toString("hex");
  const expiresAt = new Date(Date.now() + SESSION_DURATION);
  await db.query(
    "INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, $3)",
    [token, userId, expiresAt]
  );

  res.cookie("session", token, { ...cookieOptions, maxAge: SESSION_DURATION });
}

router.post("/register", async (req, res) => {
  const { email, password } = req.body ?? {};

  if (typeof email !== "string" || !EMAIL_REGEX.test(email.trim())) {
    return res.status(400).json({ error: "Email invalide" });
  }
  if (typeof password !== "string" || password.length < 8) {
    return res.status(400).json({ error: "Le mot de passe doit faire au moins 8 caractères" });
  }

  const cleanEmail = email.trim().toLowerCase();
  const existing = await db.query("SELECT id FROM users WHERE email = $1", [cleanEmail]);
  if (existing.rows.length > 0) {
    return res.status(409).json({ error: "Un compte existe déjà avec cet email" });
  }

  const { rows } = await db.query(
    "INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email",
    [cleanEmail, hashPassword(password)]
  );

  await startSession(res, rows[0].id);
  res.status(201).json(rows[0]);
});

router.post("/login", async (req, res) => {
  const { email, password } = req.body ?? {};
  if (typeof email !== "string" || typeof password !== "string") {
    return res.status(400).json({ error: "Email et mot de passe obligatoires" });
  }

  const { rows } = await db.query(
    "SELECT id, email, password_hash FROM users WHERE email = $1",
    [email.trim().toLowerCase()]
  );
  const user = rows[0];

  // Même réponse que l'email ou le mot de passe soit faux
  if (!user || !verifyPassword(password, user.password_hash)) {
    return res.status(401).json({ error: "Email ou mot de passe incorrect" });
  }

  await startSession(res, user.id);
  res.json({ id: user.id, email: user.email });
});

router.post("/logout", async (req, res) => {
  const token = req.cookies.session;
  if (typeof token === "string") {
    await db.query("DELETE FROM sessions WHERE token = $1", [token]);
  }
  res.clearCookie("session", cookieOptions);
  res.status(204).end();
});

router.get("/me", requireAuth, (req, res) => {
  res.json(req.user);
});

export default router;
  • RETURNING id, email : l'INSERT renvoie la ligne créée. Plus besoin de lastInsertRowid ni d'un SELECT derrière.
  • On écrit RETURNING id, email et pas RETURNING * : avec l'étoile, le password_hash partirait dans la réponse.
  • expiresAt est une vraie date JavaScript : pg la convertit pour la colonne TIMESTAMPTZ.
  • T'as ajouté le limiteur de tentatives de l'étape 6 ? Garde son import, son loginLimiter, et remets-le dans router.post("/login", loginLimiter, async (req, res) => ...).
routes/tasks.js
import { Router } from "express";
import { db } from "../db.js";
import { requireAuth } from "../middleware/requireAuth.js";

const router = Router();

// Toutes les routes de ce fichier exigent d'être connecté
router.use(requireAuth);

function isValidText(text) {
  return typeof text === "string" && text.trim() !== "" && text.trim().length <= 200;
}

// Un id valide : un entier positif qui tient dans un INTEGER Postgres.
// Sinon null, et la requête ne trouvera rien (404).
function parseId(value) {
  const id = Number(value);
  return Number.isInteger(id) && id > 0 && id < 2 ** 31 ? id : null;
}

router.get("/", async (req, res) => {
  const { done } = req.query;
  let result;
  if (done === "true" || done === "false") {
    result = await db.query(
      "SELECT id, text, done FROM tasks WHERE user_id = $1 AND done = $2 ORDER BY id",
      [req.user.id, done === "true"]
    );
  } else {
    result = await db.query(
      "SELECT id, text, done FROM tasks WHERE user_id = $1 ORDER BY id",
      [req.user.id]
    );
  }
  res.json(result.rows);
});

router.get("/:id", async (req, res) => {
  const id = parseId(req.params.id);
  const { rows } = await db.query(
    "SELECT id, text, done FROM tasks WHERE id = $1 AND user_id = $2",
    [id, req.user.id]
  );
  if (rows.length === 0) {
    return res.status(404).json({ error: "Tâche introuvable" });
  }
  res.json(rows[0]);
});

router.post("/", async (req, res) => {
  const { text } = req.body ?? {};
  if (!isValidText(text)) {
    return res.status(400).json({ error: "Le texte doit faire entre 1 et 200 caractères" });
  }
  const { rows } = await db.query(
    "INSERT INTO tasks (user_id, text) VALUES ($1, $2) RETURNING id, text, done",
    [req.user.id, text.trim()]
  );
  res.status(201).json(rows[0]);
});

router.patch("/:id", async (req, res) => {
  const id = parseId(req.params.id);
  const { text, done } = req.body ?? {};

  if (text !== undefined && !isValidText(text)) {
    return res.status(400).json({ error: "Le texte doit faire entre 1 et 200 caractères" });
  }
  if (done !== undefined && typeof done !== "boolean") {
    return res.status(400).json({ error: "done doit valoir true ou false" });
  }

  const found = await db.query(
    "SELECT id, text, done FROM tasks WHERE id = $1 AND user_id = $2",
    [id, req.user.id]
  );
  const task = found.rows[0];
  if (!task) {
    return res.status(404).json({ error: "Tâche introuvable" });
  }

  const newText = text !== undefined ? text.trim() : task.text;
  const newDone = done !== undefined ? done : task.done;
  const { rows } = await db.query(
    "UPDATE tasks SET text = $1, done = $2 WHERE id = $3 AND user_id = $4 RETURNING id, text, done",
    [newText, newDone, id, req.user.id]
  );
  res.json(rows[0]);
});

router.delete("/:id", async (req, res) => {
  const id = parseId(req.params.id);
  const result = await db.query(
    "DELETE FROM tasks WHERE id = $1 AND user_id = $2",
    [id, req.user.id]
  );
  if (result.rowCount === 0) {
    return res.status(404).json({ error: "Tâche introuvable" });
  }
  res.status(204).end();
});

export default router;
  • SELECT id, text, done : on choisit les colonnes, et comme done est un BOOLEAN, la ligne a déjà la forme { id, text, done } du front.
  • parseId : SQLite acceptait n'importe quoi comme id. Postgres plante sur NaN ou sur un nombre trop grand. On transforme les ids invalides en null, la requête ne trouve rien, 404.
  • result.rowCount : le nombre de lignes touchées, l'équivalent de result.changes.
  • Si une requête plante (base injoignable, faute de SQL), Express 5 attrape l'erreur des fonctions async et l'envoie à ton middleware d'erreur : 500 en JSON, sans rien écrire de plus.

Lance npm run dev et teste avec ton requests.http (REST Client garde le cookie de session entre les requêtes) :

requests.http
POST http://localhost:3000/api/auth/register
Content-Type: application/json

{ "email": "alex@exemple.com", "password": "motdepasse123" }

###
POST http://localhost:3000/api/tasks
Content-Type: application/json

{ "text": "Déployer sur Render" }

###
PATCH http://localhost:3000/api/tasks/1
Content-Type: application/json

{ "done": true }

###
GET http://localhost:3000/api/tasks?done=true

###
GET http://localhost:3000/api/tasks/abc
Erreur fréquente

Un await oublié : const { rows } = db.query(...) récupère une promesse, rows vaut undefined, et tu as « Cannot read properties of undefined (reading 'length') ». Autre classique : un ? resté dans une requête, Postgres répond « syntax error ». Cherche les ? et les .prepare qui traînent.

À toi

Ajoute GET /api/tasks/stats, qui renvoie { total, done, todo } pour l'utilisateur connecté, en une seule requête SQL. Indices : COUNT(*) FILTER (WHERE done) compte seulement les lignes cochées. Déclare la route avant /:id, sinon Express croit que stats est un id. Et pg renvoie les COUNT en texte ("3") : regarde ce que fait COUNT(*)::int.

Demande à l'IA
Dans mon API Express avec node-postgres, explique-moi pourquoi db.query renvoie une promesse alors que node:sqlite répondait tout de suite, et comment Express 5 gère une erreur lancée dans une route async. Ne réécris pas mon code.
Résultat attendu
Réponses
POST /api/auth/register    → 201  {"id":1,"email":"alex@exemple.com"}
POST /api/tasks            → 201  {"id":1,"text":"Déployer sur Render","done":false}
PATCH /api/tasks/1         → 200  {"id":1,"text":"Déployer sur Render","done":true}
GET /api/tasks?done=true   → 200  [{"id":1,"text":"Déployer sur Render","done":true}]
GET /api/tasks/abc         → 404  {"error":"Tâche introuvable"}

Le JSON est exactement le même qu'avant : done est un vrai booléen. Ouvre http://localhost:3000 : la to-do et la page de connexion marchent sans changer une ligne du front. Coupe le serveur, relance-le : tout est encore là, et visible dans Neon, menu Tables.

Mission 5

Ton serveur marche en local, avec une base en ligne. Il reste à le faire tourner sur une machine allumée en permanence. Render fait ça, gratuitement, à partir de ton dépôt GitHub.

Une ligne à ajouter dans server.js, juste après la création de app :

server.js
const app = express();
const PORT = process.env.PORT || 3000;

// Sur Render, ton serveur est derrière un proxy qui gère le HTTPS
app.set("trust proxy", 1);

app.use(express.json());
  • Sur Render, le visiteur parle en HTTPS à un « proxy », un serveur d'entrée qui transmet la requête au tien. trust proxy dit à Express de croire ce que le proxy lui raconte : la requête d'origine était en HTTPS, et voici la vraie IP du visiteur.
  • Sans ça, Express croit que tout arrive en HTTP, depuis la même IP. Un limiteur de tentatives comme express-rate-limit bloquerait alors tous tes visiteurs d'un coup, comme une seule personne.
  • Le 1 : on fait confiance à un seul intermédiaire, celui de Render.

Envoie le projet sur GitHub, comme à l'étape 5 du frontend :

Terminal
# Vérifie d'abord que .env n'apparaît PAS dans la liste
$ git status

# Si le dossier n'est pas encore un dépôt Git
$ git init

$ git add .
$ git commit -m "Passe sur Postgres et prépare la mise en ligne"

# Crée un dépôt vide api-taches sur GitHub, puis (remplace TON-PSEUDO)
$ git remote add origin https://github.com/TON-PSEUDO/api-taches.git
$ git branch -M main
$ git push -u origin main

Puis sur Render :

  1. Inscris-toi sur render.com avec ton compte GitHub. Render peut te demander une carte bancaire pour vérifier ton compte (contre les abus) : le plan Free ne débite rien.
  2. New → Web Service, autorise Render à lire tes dépôts, choisis api-taches.
  3. Remplis le formulaire comme ci-dessous. Pour les variables, colle ta connection string Neon et ajoute NODE_ENV.
  4. Clique sur Deploy Web Service, et regarde les logs défiler.
Render : New Web Service
Name              api-taches
Language          Node
Branch            main
Region            Frankfurt (EU Central)
Root Directory    (vide)
Build Command     npm install
Start Command     npm start
Instance Type     Free

Environment Variables
DATABASE_URL      postgresql://neondb_owner:...@ep-...-pooler.eu-central-1.aws.neon.tech/neondb?sslmode=verify-full&channel_binding=require
NODE_ENV          production
  • Build Command : lancée une fois par déploiement, pour installer les paquets. Start Command : lance ton serveur.
  • Pas de PORT à rentrer : Render le fournit lui-même (10000). Ton process.env.PORT || 3000 s'en occupe.
  • NODE_ENV=production : ton cookie de session passe en secure, il ne voyagera qu'en HTTPS.
  • Render utilise Node 24 par défaut : --env-file-if-exists et tout ton code marchent avec.
  • Pas de .env sur Render (il est dans le .gitignore) : c'est voulu, les variables viennent du formulaire.
Erreur fréquente

Le build passe mais le déploiement échoue avec « DATABASE_URL manquante » : la variable n'est pas rentrée sur Render, ou mal nommée. Va dans Environment, corrige, Render redéploie. Autre classique : « Cannot find module './routes/tasks.js' » alors que tout marche chez toi. Un fichier pas commité, ou une majuscule : sur Mac et Windows, Tasks.js et tasks.js c'est pareil, sur un serveur Linux non.

Résultat attendu
Logs Render
==> Cloning from https://github.com/TON-PSEUDO/api-taches
==> Checking out commit 3e7f1a2 in branch main
==> Using Node.js version 24.11.0 (default)
==> Running build command 'npm install'...
added 98 packages, and audited 99 packages in 3s
==> Build successful
==> Deploying...
==> Running 'npm start'

> api-taches@1.0.0 start
> node --env-file-if-exists=.env server.js

Serveur lancé sur http://localhost:10000
==> Your service is live

Les lignes exactes varient un peu. Ce qui compte : « Build successful », ton console.log avec le port 10000 (celui que Render donne dans PORT), puis « live ». En haut de la page, ton adresse : https://api-taches.onrender.com (avec un suffixe si le nom est pris).

Mission 6

Ouvre ton adresse Render. Si rien ne vient pendant une minute, c'est normal : en plan gratuit, le serveur s'endort après 15 minutes sans visite, et le premier visiteur attend qu'il se réveille (environ une minute). Ensuite, tout est rapide. Pour une démo, ouvre le site une minute avant.

Ajoute une route qui dit si tout va bien. Pratique pour vérifier en un coup d'œil que le serveur tourne et que la base répond :

server.js
import express from "express";
import cookieParser from "cookie-parser";
import { db } from "./db.js";
import authRouter from "./routes/auth.js";
import tasksRouter from "./routes/tasks.js";

// ... app, trust proxy, json, cookies, logger, static : sans changement ...

// Le serveur répond ET la base répond ?
app.get("/api/health", async (req, res) => {
  await db.query("SELECT 1");
  res.json({ status: "ok", time: new Date().toISOString() });
});

app.use("/api/auth", authRouter);
app.use("/api/tasks", tasksRouter);
  • SELECT 1 : la requête la plus simple possible. Si Neon ne répond pas, ça plante, et ton middleware d'erreur renvoie un 500.
  • Place-la avant la route 404 finale, sinon elle ne sera jamais atteinte.
  • Ne mets pas cette adresse dans le champ « Health Check Path » de Render. Render l'appellerait sans arrêt, ta base Neon ne s'endormirait jamais, et tu brûlerais tes heures gratuites de calcul.

Commit, push, attends le déploiement, puis teste depuis ton terminal :

Terminal
# Remplace par TON adresse Render
$ curl https://api-taches.onrender.com/api/health

# Sans cookie, la liste des tâches doit être refusée
$ curl -i https://api-taches.onrender.com/api/tasks

Ensuite, sur ton téléphone : crée un compte, ajoute des tâches, coche, supprime. Dans Render, ouvre l'onglet Logs : chaque requête s'y affiche, et c'est là que tu trouveras les erreurs. Une page qui affiche « Erreur serveur » ? Le console.error(err) de ton middleware d'erreur a écrit le vrai message dans ces logs.

Erreur fréquente

Tu te connectes, et la page te renvoie aussitôt vers la connexion. Ouvre l'onglet Réseau, regarde la réponse de /api/auth/login : pas de Set-Cookie, ou un cookie ignoré ? Vérifie que tu es bien en https://, et que ton front appelle des chemins relatifs (/api/...) et pas http://localhost:3000/api/..., resté du développement.

À toi

Prouve que tes données ne disparaissent plus. Crée un compte en ligne et trois tâches. Dans Render, menu Manual Deploy → Restart service. Reconnecte-toi : tout doit être là. Puis crée un deuxième compte dans une fenêtre privée, et vérifie qu'il ne voit pas les tâches du premier. Indice : si tu doutes, regarde les tables dans Neon et compare les user_id.

Résultat attendu
Terminal
$ curl https://api-taches.onrender.com/api/health
{"status":"ok","time":"2026-10-08T09:41:07.512Z"}

$ curl -i https://api-taches.onrender.com/api/tasks
HTTP/2 401
content-type: application/json; charset=utf-8
...

{"error":"Connecte-toi d'abord"}
Onglet Réseau, réponse de /api/auth/login
Set-Cookie: session=694daac8e68f...; Max-Age=2592000; Path=/; Expires=Sat, 07 Nov 2026 09:42:10 GMT; HttpOnly; Secure; SameSite=Lax

En ligne, le cookie porte Secure : il ne voyage qu'en HTTPS. Dans les logs Render, une ligne par requête, grâce à ton logger.

Mission 7

Comme Vercel, Render surveille ton dépôt : chaque push sur main redéploie le serveur tout seul. Ta routine :

Terminal
# 1. Tu modifies, tu testes en local
$ npm run dev

# 2. Tu enregistres et tu envoies
$ git add .
$ git commit -m "Ajoute un compteur de tâches restantes"
$ git push

# 3. Render voit le push, refait npm install puis npm start
# 4. Deux ou trois minutes plus tard, c'est en ligne
  • Onglet Events sur Render : la liste des déploiements, avec le message de chaque commit.
  • Un déploiement casse tout ? Sur un ancien déploiement réussi, le bouton Rollback remet la version d'avant en ligne, le temps de corriger.
  • Pendant un déploiement, l'ancienne version reste en ligne. Si le nouveau build échoue, tes visiteurs ne voient rien.

Dernière touche : un README.md, pour que n'importe qui (toi dans six mois compris) sache lancer le projet :

README.md
# api-taches

Une to-do list avec comptes : Node.js, Express, Postgres.
En ligne : https://api-taches.onrender.com

## Lancer en local

1. `npm install`
2. Copie `.env.example` en `.env` et mets ta connection string Neon dans `DATABASE_URL`
3. `npm run db:init` (crée les tables)
4. `npm run dev`, puis ouvre http://localhost:3000

## Hébergement

- Serveur : Render (Web Service, plan Free)
- Base : Postgres sur Neon (plan Free)
Erreur fréquente

Tu changes schema.sql, tu pousses, et le site en ligne plante sur une colonne qui n'existe pas. Render a redéployé le code, mais personne n'a touché à la base. Le schéma, c'est toi qui le lances (npm run db:init) avant de pousser le code qui en a besoin.

À toi

Pour l'instant, quand tu testes en local, tu écris dans la même base que le site en ligne. Pas terrible quand il aura de vrais utilisateurs. Neon sait créer des branches de base, comme Git : une copie à part. Crée une branche dev dans Neon et utilise-la dans ton .env local. Indice : chaque branche a sa propre connection string (bouton Connect, menu des branches). Et maintenant, comment ton schéma arrive-t-il sur la base de prod ? Regarde ce que donnerait npm install && npm run db:init comme Build Command sur Render.

Demande à l'IA
Explique-moi ce que sont les migrations de base de données, et pourquoi un CREATE TABLE IF NOT EXISTS ne suffit plus dès qu'une table contient de vraies données. Donne un exemple avec l'ajout d'une colonne, sans écrire mon projet à ma place.
Résultat attendu
Terminal et Render
$ git push
...
To https://github.com/TON-PSEUDO/api-taches.git
   3e7f1a2..8c41d09  main -> main

Render, onglet Events :
Deploy live for 8c41d09: Ajoute un compteur de tâches restantes
Deploy live for 3e7f1a2: Passe sur Postgres et prépare la mise en ligne

Chaque push donne un nouveau déploiement, avec le message de ton commit. Les tâches et les comptes ne bougent pas : ils sont dans Neon, pas sur le serveur.

Le projet

Ton backend en ligne, avec un vrai usage

Mets en ligne ta to-do avec comptes, puis donne-lui un vrai usage : un formulaire de contact pour le site de ton client de l'étape 7 du frontend (ou pour ton portfolio). Les visiteurs envoient un message, il est enregistré en base, et seul le propriétaire peut les lire.

  • Une table messages dans schema.sql : nom, email, message, date d'envoi.
  • POST /api/messages, ouverte à tous : valide chaque champ (texte non vide, longueur max, email qui ressemble à un email), 201 si c'est bon.
  • GET /api/messages et une page public/messages.html qui les liste, du plus récent au plus ancien. Protégée par requireAuth, et réservée au propriétaire : mets son email dans une variable OWNER_EMAIL et compare-le à req.user.email.
  • Si le site du client est sur une autre adresse (Vercel, son domaine), son formulaire appelle ton API depuis une autre origine : souviens-toi du CORS de l'étape 5, avec origin réglé sur l'adresse exacte de son site.
  • Affiche les messages avec textContent : n'importe qui peut écrire n'importe quoi dans un formulaire public.

Ton projet est validé quand :

Bonus

Limite les envois du formulaire avec express-rate-limit (par exemple 5 par heure et par IP) : c'est là que trust proxy sert vraiment. Envie de recevoir un email à chaque message ? Le plan gratuit de Render bloque l'envoi d'emails par SMTP (ports 25, 465 et 587) : passe par un service d'envoi qui a une API HTTP, avec sa clé dans les variables d'environnement.

Pour aller plus loin

Parcours backend bouclé. Bravo.

T'as construit un vrai backend, de A à Z : un serveur, une API, une base de données, des comptes sécurisés, et le tout en ligne, branché sur un front. C'est exactement ce qu'il y a derrière la plupart des applis que tu utilises. La suite, c'est d'en faire quelque chose : des projets pour de vrais gens, et quelqu'un qui les paie. C'est ce qu'on fait chez les Builders : rejoins la communauté, et montre-nous ce que t'as mis en ligne.

Rejoindre les Builders