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 Project created: 4f9c21ab8d3e47b0a6c95e1d2b3f7a08 (saas-demo, fr-par-1) Endpoint: ep-4f9c21ab8d3e Connection: 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" -tAc "SELECT version()" PostgreSQL 17.… $ lampion residency 4f9c21ab8d3e47b0a6c95e1d2b3f7a08 Paris 1 — Scaleway DC2 — Vitry-sur-Seine Country: France | Host certifications (Scaleway): ISO 27001, HDS, SOC 2 Type II, PCI DSS Lampion certifications: none yet (host certifications do not cover Lampion)
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, et les mots de passe masqués, dans tout l’article.
Prérequis
Deux niveaux de cloisonnement. Entre projets, Lampion sépare le stockage : chaque projet est un tenant, avec son propre historique. Dans un projet, une colonne tenant_id et une policy PostgreSQL font refuser la ligne par le moteur.
Sur Lampion, chaque projet est un tenant du moteur de stockage : ses pages, son WAL et ses fichiers dans le stockage objet sont rangés sous son propre identifiant. Depuis le 10 octobre 2026, le serveur de pages vérifie à chaque lecture un jeton propre au projet : un compute ne lit que les pages de son tenant. Chaque projet a aussi son propre historique — restaurer un projet à un instant donné ne touche aucun autre projet — et supprimer un projet supprime son tenant, sur le serveur de pages, le safekeeper et le stockage objet.
Ce qu’un projet par client apporte
Le prix : une migration et une connection string par client. Startup autorise jusqu’à 1 000 projets, Souverain bien davantage ; au-delà de quelques dizaines de clients, le schéma partagé reste le plus simple à exploiter.
Vous avez donc deux façons de séparer vos clients. Un projet par client : le cloisonnement va jusqu’au stockage, et chaque client a son historique, sa restauration et son effacement. Un schéma partagé avec row-level security : tous les clients dans un même projet, filtrés par le moteur PostgreSQL. Les deux cohabitent très bien — vos grands comptes dans leur propre projet, les autres dans un projet partagé —, et la suite de cette étape met en place le second, qui demande le plus de rigueur.
-- 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 4f9c21ab8d3e47b0a6c95e1d2b3f7a08 ep-4f9c21ab8d3e app_user --password "$APP_PASSWORD" Role 'app_user' created. $ 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 : dans la transaction, parce que la connexion, elle, passe d’un client à l’autre.
// 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')
// Local : annulé au COMMIT, avant que le pool ne prête la connexion à un autre client.
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
Gardez ce pool petit et ses connexions longues : le proxy de Lampion accepte au plus 50 connexions simultanées par endpoint, et 20 nouvelles connexions par minute depuis une même adresse IP — moins que le max_connections du compute, de 100 à 0,25 CU à 3 357 à 8 CU. Une connexion par requête HTTP bute vite sur la seconde limite.
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 forkée depuis main, supprimée d’office au bout de 72 h $ lampion branches create 4f9c21ab8d3e47b0a6c95e1d2b3f7a08 pr-482 --parent 4f9c21ab8d3e47b0a6c95e1d2b3f7a08 --ttl 72 Branch created: 9b2e7c41d0a54f3e8c6a1b7d2e9f0c35 (pr-482)
Ce que ça débloque
# Une base éphémère par pull request : supprimée à la fermeture, TTL en filet
name: preview
on:
pull_request:
types: [opened, synchronize, reopened, closed]
# Un seul run à la fois par pull request : deux pushes rapprochés ne créent pas deux branches.
concurrency: preview-${{ github.event.number }}
env:
LAMPION_TOKEN: ${{ secrets.LAMPION_TOKEN }}
PROJECT_ID: 4f9c21ab8d3e47b0a6c95e1d2b3f7a08
BRANCH: pr-${{ github.event.number }}
jobs:
test:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install lampion-cli
- name: Créer la branch au premier passage, la remettre à neuf ensuite
run: |
BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
| jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
if [ -z "$BRANCH_ID" ]; then
# TTL de 72 h : le filet si le job cleanup ne passe pas
lampion branches create "$PROJECT_ID" "$BRANCH" \
--parent "$PROJECT_ID" --ttl 72 --json
else
# Push suivant : repartir de l'état courant de main
lampion branches reset "$PROJECT_ID" "$BRANCH_ID" --yes
fi
- name: Récupérer son URL
run: |
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: |
npm ci
npm run migrate # les migrations de la PR, sur la copie de la production
npm test
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install lampion-cli
- name: Supprimer la branch
run: |
BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
| jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
# Vide si le TTL l'a déjà supprimée : rien à faire
if [ -n "$BRANCH_ID" ]; then
lampion branches delete "$PROJECT_ID" "$BRANCH_ID"
fi Aucun frais par branche, quel que soit le plan — seuls le compute consommé et le stockage sont facturés. 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 questions de l’ouverture, et celles qui les suivent toujours, attendent des réponses écrites. Voici où trouver chacune, et ce qu’on peut réellement affirmer.
# Le questionnaire pré-rempli, à donner à votre assistant IA $ lampion compliance questionnaire --format md -o questionnaire.md # Accepter le DPA au nom de la société, puis télécharger l’attestation $ lampion compliance dpa-accept --company "Acme SAS" --signer "Camille Martin" --title "CEO" $ lampion compliance dpa-certificate --pdf dpa-attestation.pdf # Le rapport de résidence et la liste des sous-traitants $ lampion compliance residency --pdf residence.pdf $ lampion compliance subprocessors
Les mêmes documents, en ligne de commande. Chaque réponse du questionnaire porte un statut — oui, partiel, non, prévu — que l’assistant ne doit pas arrondir. Seul l’owner de l’organisation peut accepter le DPA. Les documents sont en français par défaut ; --locale en donne la version anglaise.
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 du stockage par projet et 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.
Neuf 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.