← Retour au blog

Créer un SaaS B2B souverain avec Lampion

Guide · 11 min de lecture · Équipe Lampion

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.

Contexte

Ce que l’acheteur va demander

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.

Localisation
Où tournent vos serveurs, et quelle société les opère. La réponse attendue est une région et un nom, pas un continent.
Cloisonnement
Comment votre application empêche techniquement un client de lire les lignes d’un autre.
Accès interne
Qui, chez vous, peut lire une donnée client en clair.
Réversibilité
Un export exploitable, dans un format standard, le jour où le client s’en va.

Les deux premières questions se règlent dans les étapes 01 et 03. Les deux autres tombent presque toutes seules ensuite.

Étape 01

Choisir où tout tourne

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.

Où passent les données d’un client API · backend tout, en mémoire Base de données la source de vérité Stockage fichiers pièces jointes, exports E-mails noms, adresses, objets Journaux · métriques identifiants, requêtes Sauvegardes une copie de tout Une seule brique hébergée hors d’Europe suffit à rendre la réponse au questionnaire fausse.
Fig. 1 — Les données personnelles ne vivent pas que dans la base. Chaque brique en manipule une copie : l’hébergement se raisonne sur la ligne entière, pas sur le seul Postgres.

Pourquoi l’opérateur compte autant que la région

La loi qui s’applique
Un centre de données en France opéré par une filiale d’un groupe non européen reste rattaché au droit de sa maison mère. La question de votre acheteur porte sur la société, pas sur le bâtiment.
La chaîne complète
Vous êtes responsable de vos sous-traitants. Chaque fournisseur de la ligne précédente doit être nommé dans le contrat que vous signerez avec votre client.
La réversibilité
Des formats standards — Postgres, S3, conteneurs — vous laissent changer d’avis. C’est aussi ce qui rassure un acheteur sur votre propre pérennité.
Le coût réel
Les fournisseurs français facturent en euros, sans frais de sortie surprise sur le transfert. Sur une petite infrastructure, l’écart est rarement à leur défaveur.

Où faire tourner quoi

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.

BriqueOptions françaises ou européennesCe qu’il faut vérifier
Base de donnéesLampion (région fr-par-1), ou un Postgres managé chez Scaleway, OVHcloud, Clever CloudLa région, et surtout où partent les sauvegardes — c’est souvent là que la donnée sort
API · backendConteneurs ou machines chez Scaleway, OVHcloud, Outscale ; plateforme applicative Clever CloudLe pays d’exécution, mais aussi celui du plan de contrôle et du registre d’images
Stockage fichiersStockage objet compatible S3 chez Scaleway ou OVHcloudLa région du bucket, le chiffrement, et toute réplication automatique hors région
E-mails transactionnelsFournisseurs européens ; à défaut, réduire le contenu au strict minimumCe que contient l’e-mail : un objet de message suffit parfois à révéler une donnée de santé
Journaux · métriquesCollecte et stockage en Europe, rétention courteLes logs applicatifs contiennent presque toujours des identifiants clients
CDN · edgePoints de présence européens, ou pas de CDN devant les routes authentifiéesUn cache est une copie, et il vit là où se trouve le point de présence
Les certifications bougent, les offres aussi : ne recopiez pas cette table dans un contrat. Les quatre critères qui tiennent dans le temps sont le siège social et l’actionnariat de l’opérateur, le lieu d’exécution réel, la chaîne de sous-traitance nommée, et les certifications vérifiées à la date où vous répondez.
Étape 02

Créer la base en France

Trois commandes. La région se choisit à la création du projet et les données n’en sortent pas.

bashterminal
# 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

Un compte
Le plan Free suffit : 3 projets, 3 branches, un compute de 0,25 CU et 512 Mo de stockage, sans carte bancaire.
Une API key
À générer dans Settings › API Keys de la console. Préfixe lmp_live_, valable 90 jours par défaut.
Python 3.9+
Le CLI est un paquet PyPI. Tout ce qu’il fait existe aussi en REST sur https://api.lampion.cloud/v1.
Étape 03

Isoler chaque client

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.

01 Schéma partagé une colonne tenant_id sur chaque table 1 database · 1 schéma · N clients public.orders tenant_1 tenant_2 tenant_3 tenant_1 tenant_2 + Le moins cher, une seule migration − Une policy oubliée et les lignes fuient 02 Schéma par client search_path positionné par requête 1 database · N schémas tenant_1.orders tenant_2.orders tenant_3.orders + Migration client par client − Le catalogue enfle, N fois chaque table 03 Database par client une database = un client 1 compute · N databases db_1 1 db_2 2 db_3 3 + Isolation forte, restauration ciblée − N migrations, N pools de connexions
Fig. 2 — De gauche à droite, l’isolation augmente et le coût d’exploitation aussi. Ce guide prend la première, qui suffit tant qu’aucun contrat n’exige mieux.
sqlschema.sql
-- 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.

sqlpolicies.sql
-- 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é.

bashterminal
# 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
Le piège classique

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.

Étape 04

Brancher l’application

La policy lit app.tenant_id. Il reste à écrire ce paramètre au bon endroit — et le pooler impose lequel.

01 Requête HTTP jeton de session → tenant résolu 02 Pooler PgBouncer pool_mode = transaction 03 BEGIN set_config('app.tenant_id', $1, true) ← local 04 Policy RLS USING (tenant_id = NULLIF(current_setting…)) 05 COMMIT DISCARD ALL connexion rendue au pool Lampion place un pooler PgBouncer en transaction mode devant chaque compute : la connexion serveur retourne au pool à chaque COMMIT. Un SET sans LOCAL survivrait à la transaction et serait hérité par le client suivant qui récupère la même connexion.
Fig. 3 — Le paramètre app.tenant_id ne vit que le temps d’une transaction : c’est ce qui rend le multiplexage de connexions sûr.
typescriptdb.ts — node-postgres
// 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

SET LOCAL
Troisième argument à true, toujours dans un BEGIN. C’est la seule forme compatible avec un pooler en transaction mode.
Rôle dédié
L’application se connecte avec app_user : non propriétaire, sans SUPERUSER ni BYPASSRLS.
Source de vérité
Le tenant vient de la session vérifiée côté serveur, résolu via memberships — jamais d’un paramètre de requête ou d’un en-tête.

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.

Étape 05

Une base par pull request

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.

main — production branch pr-482 la migration testée sur les données réelles branch pr-495 seeds rejoués — trois clients de démonstration Stockage partagé — aucune copie physique des données
Fig. 4 — Chaque branch part du LSN (Log Sequence Number) courant de main et partage son stockage. Un TTL la supprime à la fermeture de la pull request.
bashterminal
# 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

Preview par PR
Chaque pull request a sa base. Les revues portent sur le comportement réel, plus sur une intention de migration.
Seeds
Jusqu’à dix scripts SQL ordonnés par projet, rejoués à la création de chaque branch.
TTL
Une durée de vie d’une heure à trente jours supprime la branch toute seule. Aucune copie ne survit à la pull request.
Branch protégée
main peut être marquée protégée : ni reset, ni suppression accidentelle depuis un script d’intégration continue.
yaml.github/workflows/preview.yml
# 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 →

Étape 06

Répondre au questionnaire

Les quatre questions de l’ouverture attendent des réponses écrites. Voici où chacune se trouve, et ce qu’on peut réellement affirmer.

Localisation
Région fr-par-1, vérifiable par lampion residency. Données actives, journaux WAL et snapshots restent dans la région choisie.
Opérateur
Infrastructure Scaleway, société de droit français du groupe Iliad, datacenters de la région parisienne. Le détail par couche est sur la page DPA.
Le reste de la pile
La même question se pose pour votre API, vos fichiers et vos logs. C’est la table de l’étape 01, et c’est la partie que votre acheteur regardera après la base.
Cloisonnement
RLS activée et forcée, rôle applicatif non propriétaire, et un test d’isolation rejoué à chaque commit sur une branch forkée de la production.
Accès interne
RBAC owner · admin · developer · viewer · analyst, journal d’audit des actions de la console, et masquage des données personnelles pour le rôle analyste.
Réversibilité
lampion dump exporte en SQL, format custom ou CSV. Restaurez-le ailleurs une fois et notez la date : un export jamais restauré n’est pas une réversibilité.
Ce qu’on ne promet pas

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.

Checklist

Avant d’ouvrir les inscriptions

Huit vérifications, dans l’ordre où elles se cassent en production.

  1. 01Chaque brique de la pile — API, fichiers, e-mails, journaux — est hébergée dans l’Union européenne, et vous savez nommer l’opérateur de chacune.
  2. 02Toute table portant des données client a un tenant_id NOT NULL et une clé étrangère vers tenants.
  3. 03ENABLE et FORCE ROW LEVEL SECURITY sont actifs sur ces tables, avec un USING et un WITH CHECK par policy.
  4. 04Les policies comparent avec NULLIF(current_setting(…), ''), pour que le cas non renseigné refuse au lieu de lever.
  5. 05L’application se connecte avec app_user ; ADMIN_DATABASE_URL n’est jamais déployée avec le code.
  6. 06Le tenant_id est posé par set_config(…, true) dans la transaction, jamais par un SET global.
  7. 07Un test d’isolation tourne à chaque commit, sur une branch forkée de la production.
  8. 08Un export a été restauré ailleurs au moins une fois, et le DPA est prêt à joindre à une réponse d’appel d’offres.

Votre premier client,
hébergé en France.

Free : 3 projets, 3 branches, 0,25 CU, 512 Mo de stockage — sans carte bancaire. Données hébergées en France.