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 ✓ 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
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.
-- 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 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 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 — and the pooler decides which place that is.
// 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
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.
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.
# 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
# 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.
The four opening questions need written answers. Here is where each one lives, and what you can actually claim.
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.
Eight 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.