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.
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.
The first two are settled in steps 01 and 03. The other two then follow almost on their own.
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.
Why the operator matters as much as the region
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.
| Piece | French or European options | What to check |
|---|---|---|
| Database | Lampion (fr-par-1 region), or managed Postgres at Scaleway, OVHcloud, Clever Cloud | The region, and above all where the backups go — that is often where data leaves |
| API · backend | Containers or machines at Scaleway, OVHcloud, Outscale; application platform at Clever Cloud | The country of execution, but also that of the control plane and the image registry |
| File storage | S3-compatible object storage at Scaleway or OVHcloud | The bucket region, encryption, and any automatic replication out of region |
| Transactional emails | European providers; failing that, cut the content down to the strict minimum | What the email contains: a subject line alone can sometimes reveal health data |
| Logs · metrics | Collection and storage in Europe, short retention | Application logs almost always contain customer identifiers |
| CDN · edge | European points of presence, or no CDN in front of authenticated routes | A cache is a copy, and it lives wherever the point of presence is |
Three commands. The region is chosen when the project is created, and the data does not leave it.
# 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
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
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.
-- 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.
-- 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.
# 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 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.
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.
// 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
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.
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.
# 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
# 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.
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.
# 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.
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.
Nine checks, in the order in which they break in production.
Free: 3 projects, 3 branches, 0.25 CU, 512 MB of storage — no credit card. Data hosted in France.