Vendre un logiciel à des entreprises françaises, c’est passer par un questionnaire sécurité avant de passer par un bon de commande. Deux décisions y répondent presque entièrement : où tourne votre infrastructure, et comment vous séparez les données de vos clients.
Ce guide prend les deux dans l’ordre, avec les commandes qui vont avec. Comptez une heure pour arriver à une base PostgreSQL 17 hébergée en France, un client qui ne peut techniquement pas lire les données d’un autre, et une base de test éphémère à chaque pull request.
Un cycle de vente B2B s’arrête rarement sur la fonctionnalité. Il s’arrête sur l’annexe technique du contrat, et cette annexe se prépare dans l’infrastructure.
Votre interlocuteur métier est convaincu, puis le dossier passe au responsable de la sécurité et au juriste. Arrivent alors les mêmes questions : où sont nos données, qui peut les lire, comment garantissez-vous qu’un autre client n’y accède pas, et comment les récupérons-nous si nous partons. Aucune ne se répond après coup.
Les deux premières questions se règlent dans les étapes 01 et 03. Les deux autres tombent presque toutes seules ensuite.
La base de données n’est qu’une brique. Si l’API, les fichiers ou les logs partent ailleurs, la réponse « nos données sont en France » ne tient pas cinq minutes en audit.
C’est l’erreur la plus fréquente : une base hébergée en France, et le reste du service chez un fournisseur non européen. Or les données personnelles ne restent pas dans la base. Elles passent par l’API, s’écrivent dans les logs, se posent dans un cache, se retrouvent dans un e-mail transactionnel et dans les sauvegardes de tout ce qui précède. Chaque brique hébergée ailleurs est une copie ailleurs.
Pourquoi l’opérateur compte autant que la région
La liste ci-dessous n’est pas exhaustive et n’est pas un classement : ce sont des fournisseurs français ou européens couramment utilisés pour chaque brique. Ce qui compte, ce sont les critères de la dernière colonne.
| Brique | Options françaises ou européennes | Ce qu’il faut vérifier |
|---|---|---|
| Base de données | Lampion (région fr-par-1), ou un Postgres managé chez Scaleway, OVHcloud, Clever Cloud | La région, et surtout où partent les sauvegardes — c’est souvent là que la donnée sort |
| API · backend | Conteneurs ou machines chez Scaleway, OVHcloud, Outscale ; plateforme applicative Clever Cloud | Le pays d’exécution, mais aussi celui du plan de contrôle et du registre d’images |
| Stockage fichiers | Stockage objet compatible S3 chez Scaleway ou OVHcloud | La région du bucket, le chiffrement, et toute réplication automatique hors région |
| E-mails transactionnels | Fournisseurs européens ; à défaut, réduire le contenu au strict minimum | Ce que contient l’e-mail : un objet de message suffit parfois à révéler une donnée de santé |
| Journaux · métriques | Collecte et stockage en Europe, rétention courte | Les logs applicatifs contiennent presque toujours des identifiants clients |
| CDN · edge | Points de présence européens, ou pas de CDN devant les routes authentifiées | Un cache est une copie, et il vit là où se trouve le point de présence |
Trois commandes. La région se choisit à la création du projet et les données n’en sortent pas.
# Installer le CLI et poser le jeton $ pip install lampion-cli $ export LAMPION_TOKEN=lmp_live_xxxxxxxxxxxxxxxx # Créer le projet dans la région de Paris $ lampion projects create saas-demo --region fr-par-1 ✓ prj_a1b2c3d4 saas-demo fr-par-1 ep-4f9c21ab8d3e postgresql://cloud_admin:••••@db.lampion.cloud:5432/ep-4f9c21ab8d3e.postgres?sslmode=require # Cette chaîne est celle du propriétaire : elle sert aux migrations $ export ADMIN_DATABASE_URL="postgresql://cloud_admin:••••@db.lampion.cloud:5432/ep-4f9c21ab8d3e.postgres?sslmode=require" # Vérifier la version et la localisation $ psql "$ADMIN_DATABASE_URL" -c "SELECT version()" PostgreSQL 17.4 on x86_64-pc-linux-gnu $ lampion residency prj_a1b2c3d4 region fr-par-1 · Paris, France
Deux détails dans cette URL : le nom de database porte l’identifiant du compute, c’est ce qui permet au proxy de router la connexion ; et sslmode=require n’est pas négociable. Notez le nom de la variable — ADMIN_DATABASE_URL, pas DATABASE_URL : l’application aura son propre rôle à l’étape suivante. Les sorties sont abrégées dans tout l’article.
Prérequis
Une colonne tenant_id, une policy PostgreSQL, un rôle applicatif dédié. C’est ce qui transforme « notre code filtre bien » en « le moteur refuse de rendre la ligne ».
Trois dispositions existent pour ranger plusieurs clients dans une base. Le schéma partagé — une colonne tenant_id sur chaque table — convient à la très grande majorité des SaaS B2B et ne ferme aucune porte : un grand compte pourra recevoir sa propre database plus tard, sur le même compute, sans que le code applicatif change.
-- Toute table portant des données client a un tenant_id.
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text UNIQUE NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
amount_cts integer NOT NULL CHECK (amount_cts >= 0),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_tenant_created_idx
ON orders (tenant_id, created_at DESC); memberships est la table qui relie un utilisateur à son client : c’est elle qui permet au serveur de décider quel tenant_id poser. Elle porte aussi une policy, comme toute table contenant des données client — la row-level security ne traverse pas les jointures.
-- Le moteur filtre, plus seulement le code applicatif.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- FORCE : la policy s'applique aussi au propriétaire de la table.
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
-- Le rôle applicatif n'est jamais le propriétaire.
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;
GRANT SELECT ON tenants TO app_user; NULLIF évite un piège : après un premier set_config local suivi d’un COMMIT, la valeur peut revenir à la chaîne vide, et ''::uuid lève une erreur au lieu de filtrer. Avec NULLIF, le cas non renseigné ne rend aucune ligne — l’échec est fermé.
# Le rôle de l'application, puis sa connection string $ export APP_PASSWORD="$(openssl rand -hex 24)" $ lampion roles create prj_a1b2c3d4 ep-4f9c21ab8d3e app_user --password "$APP_PASSWORD" ✓ role app_user créé $ export DATABASE_URL="postgresql://app_user:[email protected]:5432/ep-4f9c21ab8d3e.postgres?sslmode=require" # Appliquer le schéma et les policies $ psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql -f policies.sql # Une ligne de test, écrite par le propriétaire $ psql "$ADMIN_DATABASE_URL" -c "INSERT INTO tenants (slug) VALUES ('acme')" $ psql "$ADMIN_DATABASE_URL" -c "INSERT INTO orders (tenant_id, amount_cts) SELECT id, 1000 FROM tenants" # Relue par l'application, sans tenant posé : la policy ne rend rien $ psql "$DATABASE_URL" -c "SELECT count(*) FROM orders" count ------- 0
La connection string que Lampion vous remet utilise cloud_admin, le propriétaire des objets. Si votre application se connecte avec ce rôle, FORCE ROW LEVEL SECURITY est votre seule protection et un oubli suffit à tout ouvrir. Gardez deux variables distinctes : ADMIN_DATABASE_URL pour les migrations, DATABASE_URL pour l’application. La liste des personnes qui détiennent la première est votre vraie surface d’accès interne.
La policy lit app.tenant_id. Il reste à écrire ce paramètre au bon endroit — et le pooler impose lequel.
// Une seule porte vers la base : le contexte ne peut pas être oublié.
import pg from 'pg'
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: true },
})
export async function withTenant<T>(
tenantId: string,
fn: (c: pg.PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect()
try {
await client.query('BEGIN')
// SET LOCAL : annulé au COMMIT, donc sûr derrière un pooler.
await client.query('SELECT set_config($1, $2, true)',
['app.tenant_id', tenantId])
const out = await fn(client)
await client.query('COMMIT')
return out
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
} Les trois règles
Le pooler compte plus qu’il n’y paraît : un compute à 0,25 CU accepte 50 connexions Postgres, un compute à 8 CU en accepte 800. Avec le multiplexage en transaction mode, des centaines de connexions applicatives se partagent une poignée de connexions serveur.
Une branch Lampion est un fork copy-on-write de la production au LSN courant : les vraies données, en quelques secondes, sans copie physique et sans sortir de la région.
# Une branch par pull request, forkée depuis main $ lampion branches create prj_a1b2c3d4 pr-482 --parent prj_a1b2c3d4 ✓ pr-482 · fork copy-on-write au LSN courant · seeds rejoués (3)
Ce que ça débloque
# Une base éphémère par pull request, détruite à la fermeture
name: preview
on:
pull_request:
types: [opened, synchronize, closed]
env:
LAMPION_TOKEN: ${{ secrets.LAMPION_TOKEN }}
PROJECT_ID: prj_a1b2c3d4
BRANCH: pr-${{ github.event.number }}
jobs:
test:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install lampion-cli
- name: Créer la branch et récupérer son URL
run: |
lampion branches create "$PROJECT_ID" "$BRANCH" --parent "$PROJECT_ID"
URL=$(lampion endpoints list "$PROJECT_ID" --json \
| jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .connection_string')
echo "::add-mask::$URL"
echo "ADMIN_DATABASE_URL=$URL" >> "$GITHUB_ENV"
- name: Migrations et tests
run: |
psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql -f policies.sql
npm test
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- run: pip install lampion-cli
- run: lampion branches delete "$PROJECT_ID" "$BRANCH" Le branching n’est jamais facturé, quel que soit le plan — seuls le compute consommé et le stockage le sont. Une branch inactive se suspend après cinq minutes. Pour aller plus loin et faire tourner les tests sur des données masquées, il y a un article dédié.
Tester sur des données de production masquées, à chaque pull request →
Les quatre questions de l’ouverture attendent des réponses écrites. Voici où chacune se trouve, et ce qu’on peut réellement affirmer.
Une certification ne se déclare pas, elle s’obtient — et elle appartient à celui qui la détient, pas à celui qui la revend. Ce que vous pouvez affirmer sans risque : la région, les sociétés de la chaîne de sous-traitance, les certifications que chacune détient à la date où vous répondez, le chiffrement du transport, le cloisonnement applicatif, et vos procédures d’effacement et d’export. Pour le reste, renvoyez à la page DPA. Un questionnaire rempli avec exactitude passe mieux qu’un questionnaire rempli avec optimisme.
Huit vérifications, dans l’ordre où elles se cassent en production.
Free : 3 projets, 3 branches, 0,25 CU, 512 Mo de stockage — sans carte bancaire. Données hébergées en France.