← Back to the blog

Building a sovereign B2B SaaS with Lampion

Guide · 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
✓ prj_a1b2c3d4  saas-demo  fr-par-1  ep-4f9c21ab8d3e
  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" -c "SELECT version()"
  PostgreSQL 17.4 on x86_64-pc-linux-gnu
$ lampion residency prj_a1b2c3d4
  region fr-par-1 · Paris, France

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 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 by default.
Python 3.9+
The CLI is a PyPI package. Everything it does also exists over REST at https://api.lampion.cloud/v1.
Step 03

Isolate every customer

One tenant_id column, one PostgreSQL policy, one dedicated application role. That is what turns "our code filters correctly" into "the engine refuses to return the row".

There are three ways to store several customers in one database. The shared schema — a tenant_id column on every table — fits the vast majority of B2B SaaS and closes no doors: a large account can get its own database later, on the same compute, without the application code changing.

01 Shared schema a tenant_id column on every table 1 database · 1 schema · 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 Schema per customer search_path set per request 1 database · N schemas tenant_1.orders tenant_2.orders tenant_3.orders + Migrate customer by customer − The catalog swells, every table N times 03 Database per customer one database = one customer 1 compute · N databases db_1 1 db_2 2 db_3 3 + Strong isolation, targeted restore − N migrations, N connection pools
Fig. 2 — From left to right, isolation goes up and so does operational cost. This guide takes the first, which is enough until a contract demands better.
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 prj_a1b2c3d4 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 — and the pooler decides which place that is.

01 HTTP request session token → tenant resolved 02 Pooler PgBouncer pool_mode = transaction 03 BEGIN set_config('app.tenant_id', $1, true) ← local 04 RLS policy USING (tenant_id = NULLIF(current_setting…)) 05 COMMIT DISCARD ALL connection back in pool Lampion puts a PgBouncer pooler in transaction mode in front of every compute: the server connection returns to the pool on each COMMIT. A SET without LOCAL would outlive the transaction and be inherited by the next customer that picks up the same connection.
Fig. 3 — The app.tenant_id setting only lives for the duration of one transaction: that is what makes connection multiplexing safe.
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')

    // SET LOCAL: discarded on COMMIT, so it is safe behind a 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()
  }
}

The three rules

SET LOCAL
Third argument true, always inside a BEGIN. It is the only form compatible with a pooler in transaction mode.
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.

The pooler matters more than it looks: a 0.25 CU compute accepts 50 Postgres connections, an 8 CU compute accepts 800. With transaction-mode multiplexing, hundreds of application connections share a handful of server connections.

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 Shared storage — no physical copy of the data
Fig. 4 — Every branch starts at main’s current LSN (Log Sequence Number) and shares its storage. A TTL deletes it when the pull request closes.
bashterminal
# One branch per pull request, forked from main
$ lampion branches create prj_a1b2c3d4 pr-482 --parent prj_a1b2c3d4
✓ pr-482 · copy-on-write fork at current LSN · seeds replayed (3)

What it unlocks

Preview per PR
Every pull request gets its database. Reviews are about real behaviour, not about a migration’s intent.
Seeds
Up to ten ordered SQL scripts per project, replayed whenever a branch is created.
TTL
A lifetime from one hour to thirty days deletes the branch on its own. No copy outlives the pull request.
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, destroyed on close
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: Create the branch and fetch its 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 and 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"

Branching is never billed, on any plan — only the compute you burn and the storage you keep. 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 four opening questions need written answers. Here is where each one lives, and what you can actually claim.

Location
Region fr-par-1, verifiable with lampion residency. 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 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
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. Restore it elsewhere once and write down the date: an export you never restored is not reversibility.
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, 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

Eight 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. 02Every table holding customer data has a NOT NULL tenant_id and a foreign key to tenants.
  3. 03ENABLE and FORCE ROW LEVEL SECURITY are on for those tables, with a USING and a WITH CHECK per policy.
  4. 04Policies compare with NULLIF(current_setting(…), ''), so the unset case refuses instead of raising.
  5. 05The application connects as app_user; ADMIN_DATABASE_URL is never deployed with the code.
  6. 06tenant_id is set with set_config(…, true) inside the transaction, never with a global SET.
  7. 07An isolation test runs on every commit, against a branch forked from production.
  8. 08An export has been restored elsewhere at least once, and the DPA is 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.