← Back to the blog

Building a sovereign B2B SaaS with Lampion

Guide · Updated · 11 min read · Lampion team

Selling software to French companies means going through a security questionnaire before you get to a purchase order. Two decisions answer almost all of it: where your infrastructure runs, and how you keep your customers’ data apart.

This guide takes both in order, with the commands that go with them. Set aside an hour to end up with a PostgreSQL 17 database hosted in France, a customer who technically cannot read another one’s rows, and an ephemeral test database on every pull request.

Context

What the buyer will ask

A B2B sales cycle rarely stalls on features. It stalls on the technical annex of the contract, and that annex is prepared in your infrastructure.

Your business contact is sold, then the file reaches the security lead and the legal counsel. The same questions arrive: where is our data, who can read it, how do you guarantee another customer cannot reach it, and how do we get it back if we leave. None of them can be answered after the fact.

Location
Where your servers run, and which company operates them. The expected answer is a region and a name, not a continent.
Isolation
How your application technically prevents one customer from reading another’s rows.
Internal access
Who, on your side, can read customer data in the clear.
Reversibility
A usable export, in a standard format, the day the customer leaves.

The first two are settled in steps 01 and 03. The other two then follow almost on their own.

Step 01

Choose where everything runs

The database is only one piece. If the API, the files or the logs live elsewhere, "our data is in France" does not survive five minutes of audit.

This is the most common mistake: a database hosted in France, and the rest of the service at a non-European provider. But personal data does not stay in the database. It goes through the API, gets written to logs, lands in a cache, ends up in a transactional email and in the backups of all of the above. Every piece hosted elsewhere is a copy elsewhere.

Where a customer’s data travels API · backend everything, in memory Database the source of truth File storage attachments, exports Emails names, addresses, subjects Logs · metrics identifiers, queries Backups a copy of all of it One single piece hosted outside Europe is enough to make your questionnaire answer wrong.
Fig. 1 — Personal data does not live only in the database. Every piece handles a copy of it: hosting is decided for the whole line, not for Postgres alone.

Why the operator matters as much as the region

Which law applies
A datacenter in France operated by a subsidiary of a non-European group is still tied to its parent’s law. Your buyer’s question is about the company, not the building.
The whole chain
You are accountable for your subprocessors. Every provider in the row above has to be named in the contract you sign with your customer.
Reversibility
Standard formats — Postgres, S3, containers — let you change your mind. That is also what reassures a buyer about your own longevity.
The real cost
French providers bill in euros, with no surprise egress fees. On a small infrastructure, the gap is rarely in their disfavour.

Where to run what

The list below is neither exhaustive nor a ranking: these are French or European providers commonly used for each piece. What matters is the criteria in the last column.

PieceFrench or European optionsWhat to check
DatabaseLampion (fr-par-1 region), or managed Postgres at Scaleway, OVHcloud, Clever CloudThe region, and above all where the backups go — that is often where data leaves
API · backendContainers or machines at Scaleway, OVHcloud, Outscale; application platform at Clever CloudThe country of execution, but also that of the control plane and the image registry
File storageS3-compatible object storage at Scaleway or OVHcloudThe bucket region, encryption, and any automatic replication out of region
Transactional emailsEuropean providers; failing that, cut the content down to the strict minimumWhat the email contains: a subject line alone can sometimes reveal health data
Logs · metricsCollection and storage in Europe, short retentionApplication logs almost always contain customer identifiers
CDN · edgeEuropean points of presence, or no CDN in front of authenticated routesA cache is a copy, and it lives wherever the point of presence is
Certifications move, and so do offerings: do not copy this table into a contract. The four criteria that hold over time are the operator’s registered office and ownership, the real place of execution, the named subprocessing chain, and the certifications verified on the day you answer.
Step 02

Create the database in France

Three commands. The region is chosen when the project is created, and the data does not leave it.

bashterminal
# Install the CLI and set the token
$ pip install lampion-cli
$ export LAMPION_TOKEN=lmp_live_xxxxxxxxxxxxxxxx

# Create the project in the Paris region
$ 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

# This string belongs to the owner: it is for migrations
$ export ADMIN_DATABASE_URL="postgresql://cloud_admin:••••@db.lampion.cloud:5432/ep-4f9c21ab8d3e.postgres?sslmode=require"

# Check the version and the location
$ 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)

Two details in that URL: the database name carries the compute identifier, which is how the proxy routes the connection; and sslmode=require is not optional. Note the variable name — ADMIN_DATABASE_URL, not DATABASE_URL: the application gets its own role in the next step. Command output is abridged, and passwords masked, throughout this article.

Prerequisites

An account
The Free plan is enough: 3 projects, 3 branches, a 0.25 CU compute and 512 MB of storage, no credit card.
An API key
Generated under Settings › API Keys in the console. lmp_live_ prefix, valid for 90 days.
Python 3.10+
The CLI is a PyPI package. Everything it does also exists over REST at https://api.lampion.cloud/v1.
Step 03

Isolate every customer

Two levels of isolation. Between projects, Lampion separates storage: every project is a tenant, with its own history. Inside a project, a tenant_id column and a PostgreSQL policy make the engine refuse the row.

On Lampion, every project is a tenant of the storage engine: its pages, its WAL and its files in object storage are kept under its own identifier. Since October 10, 2026, the page server checks a project-specific token on every read: a compute only reads pages from its own tenant. Every project also has its own history — restoring one project to a point in time touches no other project — and deleting a project deletes its tenant, on the page server, the safekeeper and object storage.

What one project per customer gives you

Separate storage
One tenant per project: pages, WAL and object-storage files kept apart. Page reads are checked against a project-specific token: a compute cannot read another customer’s pages.
Its own history
Every project keeps its history for as long as its plan allows — 12 hours on Free, 30 days on Startup, 90 days on Souverain — and restores to the second without touching other customers.
Per-project erasure
Deleting the project deletes its tenant, with its data and its history, without touching other customers. It is the simplest starting point for an erasure request or the end of a contract.
Its own compute
Every project has its own compute, which suspends when the customer is idle: a sleeping customer only costs its storage.

The price: one migration and one connection string per customer. Startup allows up to 1,000 projects, Souverain far more; beyond a few dozen customers, the shared schema remains the simplest to run.

That gives you two ways to separate your customers. One project per customer: isolation goes all the way down to storage, and every customer has its own history, restore and erasure. A shared schema with row-level security: all customers in one project, filtered by the PostgreSQL engine. The two mix well — your large accounts in their own project, everyone else in a shared one — and the rest of this step sets up the second, which takes the most discipline.

01 Shared schema a tenant_id column on every table 1 project · 1 tenant · N customers public.orders tenant_1 tenant_2 tenant_3 tenant_1 tenant_2 + Cheapest, a single migration − One missing policy and rows leak 02 Database per customer one database = one customer, same project 1 project · 1 tenant · N databases db_1 · orders db_2 · orders db_3 · orders + No policy to maintain − Shared history and restore 03 Project per customer one project = one tenant = one customer N projects · N tenants project 1 1 tenant project 2 2 tenant project 3 3 tenant + Separate storage, history, compute − N migrations, N connection strings
Fig. 2 — From left to right, isolation goes up and so does operational cost. Only the third model also separates storage and history: a database per customer still shares its project’s tenant, and therefore its history.
sqlschema.sql
-- Every table holding customer data carries a 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 is the table linking a user to their customer: it is what lets the server decide which tenant_id to set. It carries a policy too, like every table holding customer data — row-level security does not travel across joins.

sqlpolicies.sql
-- The engine filters, not just the application code.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- FORCE: the policy also applies to the table owner.
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);

-- The application role is never the owner.
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user;
GRANT SELECT ON tenants TO app_user;

NULLIF avoids a trap: after a first local set_config followed by a COMMIT, the setting can come back as an empty string, and ''::uuid raises instead of filtering. With NULLIF, the unset case returns no rows — the failure mode is closed.

bashterminal
# The application role, then its 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"

# Apply the schema and the policies
$ psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f schema.sql -f policies.sql

# One test row, written by the owner
$ 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"

# Read back by the application, with no tenant set: the policy returns nothing
$ psql "$DATABASE_URL" -c "SELECT count(*) FROM orders"
 count 
-------
     0
The classic trap

The connection string Lampion hands you uses cloud_admin, the owner of the objects. If your application connects with that role, FORCE ROW LEVEL SECURITY is your only protection and one oversight opens everything. Keep two separate variables: ADMIN_DATABASE_URL for migrations, DATABASE_URL for the application. The list of people holding the first one is your real internal access surface.

Step 04

Wire up the application

The policy reads app.tenant_id. All that is left is writing that setting in the right place: inside the transaction, because the connection itself moves from one customer to the next.

01 HTTP request session token → tenant resolved 02 App pool pool.connect() connection reused 03 BEGIN set_config('app.tenant_id', $1, true) ← local 04 RLS policy USING (tenant_id = NULLIF(current_setting…)) 05 COMMIT setting discarded connection back in pool A Lampion connection string reaches the compute through a proxy, with no pooler: the pool is your application’s own. A SET without LOCAL would outlive the transaction, and the next query — possibly another customer’s — would inherit it.
Fig. 3 — The app.tenant_id setting only lives for the duration of one transaction: that is what makes it safe to reuse a connection from one customer to the next, in your application’s pool as behind any transaction-mode pooler.
typescriptdb.ts — node-postgres
// One single door into the database: the context cannot be forgotten.
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: discarded on COMMIT, before the pool lends the connection to another customer.
    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()
  }
}

The three rules

SET LOCAL
Third argument true, always inside a BEGIN. The setting dies on COMMIT: it is the only safe form as soon as one connection serves several customers, in the application’s pool as behind a transaction-mode PgBouncer.
Dedicated role
The application connects as app_user: not the owner, no SUPERUSER, no BYPASSRLS.
Source of truth
The tenant comes from the server-verified session, resolved through memberships — never from a query parameter or a header.

Keep that pool small and its connections long-lived: Lampion’s proxy accepts at most 50 concurrent connections per endpoint, and 20 new connections per minute from a single IP address — fewer than the compute’s max_connections, from 100 at 0.25 CU to 3,357 at 8 CU. One connection per HTTP request soon hits the second limit.

Step 05

A database per pull request

A Lampion branch is a copy-on-write fork of production at the current LSN: real data, in seconds, with no physical copy and without leaving the region.

main — production branch pr-482 the migration tested against real data branch pr-495 seeds replayed — three demo customers The project’s storage, shared by its branches — no physical copy of the data
Fig. 4 — Every branch starts at main’s current LSN (Log Sequence Number) and shares its storage. The cleanup job deletes it when the pull request closes; its TTL deletes it anyway if that job does not run.
bashterminal
# A branch forked from main, deleted automatically after 72 h
$ lampion branches create 4f9c21ab8d3e47b0a6c95e1d2b3f7a08 pr-482 --parent 4f9c21ab8d3e47b0a6c95e1d2b3f7a08 --ttl 72
Branch created: 9b2e7c41d0a54f3e8c6a1b7d2e9f0c35 (pr-482)

What it unlocks

Preview per PR
Every pull request gets its database. Reviews are about real behaviour, not about a migration’s intent.
Seeds
Ordered SQL scripts, replayed whenever a branch is created or reset: one per project on Free, up to ten on Startup.
TTL
A lifetime from one hour to thirty days (--ttl, in hours) deletes the branch on its own: the safety net if the end-of-pull-request cleanup fails.
Protected branch
main can be marked protected: no reset, no accidental deletion from a CI script.
yaml.github/workflows/preview.yml
# An ephemeral database per pull request: deleted on close, TTL as a safety net
name: preview
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

# One run at a time per pull request: two pushes in quick succession do not create two 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: Create the branch on the first run, reset it afterwards
        run: |
          BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
            | jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
          if [ -z "$BRANCH_ID" ]; then
            # 72 h TTL: the safety net if the cleanup job does not run
            lampion branches create "$PROJECT_ID" "$BRANCH" \
              --parent "$PROJECT_ID" --ttl 72 --json
          else
            # Later push: start again from main's current state
            lampion branches reset "$PROJECT_ID" "$BRANCH_ID" --yes
          fi

      - name: Fetch its 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 and tests
        run: |
          npm ci
          npm run migrate   # the PR's migrations, against the copy of 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: Delete the branch
        run: |
          BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
            | jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
          # Empty if the TTL already deleted it: nothing to do
          if [ -n "$BRANCH_ID" ]; then
            lampion branches delete "$PROJECT_ID" "$BRANCH_ID"
          fi

There is no per-branch fee, on any plan — only the compute you burn and the storage you keep are billed. An idle branch suspends after five minutes. To go further and run the tests against masked data, there is a dedicated article.

Testing on masked production data, on every pull request →

Step 06

Answer the questionnaire

The opening questions, and the ones that always follow them, need written answers. Here is where to find each one, and what you can actually claim.

Location
Region fr-par-1, verifiable with lampion residency, and evidenced by a timestamped PDF residency report. Live data, WAL logs and snapshots stay in the chosen region.
Operator
Scaleway infrastructure, a French company of the Iliad group, Paris-area datacenters. The layer-by-layer detail is on the DPA page; the list of subprocessors, flagging those that actually see your data, is in the console.
The rest of the stack
The same question applies to your API, your files and your logs. That is the table in step 01, and it is the part your buyer will look at after the database.
Isolation
Between projects: one storage tenant per project, page reads checked by token — hence a dedicated project for customers who require it. Inside a project: RLS enabled and forced, a non-owner application role, and an isolation test replayed on every commit against a branch forked from production.
Internal access
RBAC owner · admin · developer · viewer · analyst, an audit log of console actions, and personal-data masking for the analyst role.
Reversibility
lampion dump exports as SQL, custom format or CSV (--format sql|custom|csv; CSV goes one table at a time). Restore it elsewhere once and write down the date: an export you never restored is not reversibility. For a customer in its own project, erasure at the end of the contract starts with deleting the project, which deletes its tenant. Do not write down a purge deadline for backup copies until you have it confirmed in writing.
Supporting documents
The console’s Compliance tab gathers them: a pre-filled security questionnaire (Markdown, CSV or JSON export), online DPA acceptance with its PDF certificate, the list of subprocessors and the PDF residency report.
bashterminal
# The pre-filled questionnaire, to hand to your AI assistant
$ lampion compliance questionnaire --format md -o questionnaire.md

# Accept the DPA on behalf of the company, then download the certificate
$ lampion compliance dpa-accept --company "Acme SAS" --signer "Camille Martin" --title "CEO"
$ lampion compliance dpa-certificate --pdf dpa-attestation.pdf

# The residency report and the list of subprocessors
$ lampion compliance residency --pdf residence.pdf
$ lampion compliance subprocessors

The same documents, from the command line. Every questionnaire answer carries a status — yes, partial, no, planned — that the assistant must not round up. Only the organization owner can accept the DPA. Documents are in French by default; --locale en gives the English version.

What not to promise

A certification is earned, not declared — and it belongs to whoever holds it, not to whoever resells it. What you can safely state: the region, the companies in the subprocessing chain, the certifications each of them holds on the day you answer, transport encryption, per-project storage isolation and application-level isolation, and your deletion and export procedures. For the rest, point to the DPA page. A questionnaire filled in accurately lands better than one filled in optimistically.

Checklist

Before you open sign-ups

Nine checks, in the order in which they break in production.

  1. 01Every piece of the stack — API, files, emails, logs — is hosted in the European Union, and you can name the operator of each.
  2. 02Customers who require separate storage, restore or erasure have their own project; the others share one.
  3. 03In a shared project, every table holding customer data has a NOT NULL tenant_id and a foreign key to tenants.
  4. 04ENABLE and FORCE ROW LEVEL SECURITY are on for those tables, with a USING and a WITH CHECK per policy.
  5. 05Policies compare with NULLIF(current_setting(…), ''), so the unset case refuses instead of raising.
  6. 06The application connects as app_user; ADMIN_DATABASE_URL is never deployed with the code.
  7. 07tenant_id is set with set_config(…, true) inside the transaction, never with a global SET.
  8. 08An isolation test runs on every commit, against a branch forked from production.
  9. 09An export has been restored elsewhere at least once, and the DPA is accepted, its PDF certificate ready to attach to a tender response.

Your first customer,
hosted in France.

Free: 3 projects, 3 branches, 0.25 CU, 512 MB of storage — no credit card. Data hosted in France.