First Production Deployment Checklist
Status: open. No item on this list has been executed. Endora Commerce has no production deployment yet.
Why this page exists
Dozens of engineering decisions in this repository were ruled safe on one ground: there is no production deployment, so nothing can break. That ruling let the platform drop compatibility shims, rebuild its migration history, and change permission gates without a migration path. It was the right call, and it was never free — it borrowed against a first deployment that has not happened yet.
Everything that ruling licensed that the code cannot carry by itself lands here: a grant somebody has to make, a setting somebody has to choose, a seed that must not run, a value that is silently wrong until an operator sets it. This page is that ledger. It is written to be executed by somebody who was not in the conversations that produced the items.
This page is not the deployment procedure. Provisioning the VPS, the container registry,
TLS, DNS and the compose stack are in deploy/README.md, and it should be followed first. This
page starts where that one stops: the stack is up, the schema is applied, and nobody has yet
decided anything about the business running on it.
Scope discipline. An item belongs here only if all three hold: it must happen before real customers transact, no code change can decide it for the operator, and getting it wrong is expensive or invisible. Items that failed one of those tests are listed at the bottom, with the reason — a checklist that silently omits something is worse than no checklist.
How to use it
Copy this page per deployment and tick items in the copy, not here. Each item names an owner: operator (a business decision, made in the Admin UI) or engineer (a value in the environment, or a command on the host). Every item states what to do and how to prove it took — "we set it" is not evidence, "we read it back" is.
A. Decisions that are baked into the build
These are frozen when CI builds the images. Changing them later means a rebuild and redeploy, so decide them before the release build runs — not after.
A1. The sales-channel code, in all three places it is written
Why. The channel code appears in three differently-named variables, and nothing checks that
they agree. The backend reconciles the row named by DEFAULT_SALES_CHANNEL_CODE as the
system-default channel at boot; the storefront bundle carries
NEXT_PUBLIC_SALES_CHANNEL_CODE, baked at image build time from the CI variable
SALES_CHANNEL_CODE. If the storefront's code names a channel that does not exist, storefront
requests fall back to the system default and per-channel content silently resolves against the
wrong channel.
Do (engineer). Agree one code with the client. Set it in:
- GitLab → Settings → CI/CD → Variables:
SALES_CHANNEL_CODE(see.gitlab-ci.yml:17, used at.gitlab-ci.yml:645); deploy/.envon the VPS:DEFAULT_SALES_CHANNEL_CODE(seedeploy/.env.prod.example);- if the deployment serves more than one domain,
SALES_CHANNEL_HOST_MAPashost=channelCodepairs.
Verify. After the deploy, GET /api/v1/admin/sales-channels lists a channel whose code
equals the value baked into the storefront, and it is the one flagged as system default. Exactly
one system-default channel always exists — if none matches, the storefront is talking to a
channel nobody configured.
A2. The default locale
Why. NEXT_PUBLIC_DEFAULT_LOCALE is baked from the CI variable DEFAULT_LOCALE
(.gitlab-ci.yml:646). It must name a row in the languages table. The migration
packages/modules/languages/src/migrations/20260425T161557_languages_currencies_init.ts seeds
exactly two languages — en-US (default) and pl-PL — because those were the demo's choice,
not this client's.
Do (engineer + operator). Set DEFAULT_LOCALE to the client's language. If the client's
default is not en-US, an operator must also flip the default flag on the language row, and
add any language the seed does not ship.
Verify. The storefront's first page render is in the expected language with no locale switch, and the Languages screen shows that language as default.
B. Environment and secrets
B1. Generate every secret freshly for this deployment
Why. deploy/.env.prod.example ships placeholders (change-me-hex-32,
change-me-base64-32). They are syntactically valid, so nothing refuses to boot: a deployment
that keeps them runs with a publicly-known session-signing key and a publicly-known
settings-encryption key. Only two things are refused at boot: a missing
SESSION_COOKIE_SECRET (backend/src/index.ts) and a missing public API origin (B2). A
placeholder secret is not — it is syntactically a secret.
Do (engineer). Generate each of SESSION_COOKIE_SECRET, ASSETS_LIBRARY_HMAC_KEY
(openssl rand -hex 32), SETTINGS_SECRET_ENCRYPTION_KEY, MFA_SECRET_ENCRYPTION_KEY,
MEILI_MASTER_KEY (openssl rand -base64 32) and a strong POSTGRES_PASSWORD. chmod 600
the file.
Verify. grep change-me /opt/b2b/.env returns nothing.
B2. Set REVALIDATE_SECRET, and know why the backend refuses to boot without a public origin
Why. Neither PUBLIC_API_BASE_URL nor REVALIDATE_SECRET used to appear in
deploy/.env.prod.example or in the x-backend-env block of deploy/compose.prod.yml, and
both failed silently. Both have since been fixed, in different ways:
PUBLIC_API_BASE_URLis the origin every payment-gateway callback (ITN/notification) URL, every public product-feed URL and every newsletter confirmation link is built on. It used to fall back tohttp://localhost:3001, so the platform handed the gateway a callback nothing on the internet can reach and no payment was ever confirmed.compose.prod.ymlnow derives it fromAPI_DOMAINalongsideBACKEND_PUBLIC_URL, and the backend refuses to boot whenNODE_ENV=productionand neither is set (packages/platform/src/kernel/public-api-base-url.ts, called first thing incomposeApp()). Nothing to fill in — but if the backend exits at boot naming this variable,API_DOMAINis what is missing.REVALIDATE_SECRETis the shared secret the backend presents to the storefront's/api/revalidateendpoint after a content write (packages/modules/catalog/src/backend/index.ts, plus the analytics and marketing modules). Unset, the revalidator is a silent no-op and the storefront endpoint answers 401: content changes do not appear until the fetch cache expires on its own. It is now indeploy/.env.prod.exampleand handed to both the backend and the storefront container — the same value, or the seam does not close.
Do (engineer). Generate REVALIDATE_SECRET (openssl rand -hex 32) into deploy/.env.
Confirm API_DOMAIN is the real public API domain.
Verify. docker compose --env-file .env -f compose.prod.yml config | grep PUBLIC_API_BASE_URL
shows the public API origin, not localhost. In the Admin UI, a gateway's configuration screen
shows a callback URL on that domain, and that URL is what is registered in the provider's own
portal. Publish a category change and confirm it appears on the storefront without waiting.
B3. Point SMTP_URL at a real relay
Why. SMTP_URL is empty in deploy/.env.prod.example and documented as optional:
"unset falls back to a console mailer" (deploy/compose.prod.yml:52). On a production
deployment that means account verification e-mails, invitations, order confirmations and
invoice deliveries are written to the container log and nowhere else. Nothing errors, and
customers simply never receive anything.
Do (engineer). Set SMTP_URL and SMTP_FROM to the client's relay and sender identity, on
a domain with SPF/DKIM aligned to that sender.
Verify. Register a test customer against the production storefront and receive the verification e-mail in a real inbox. Do this before the client's first customer does.
C. Database and first boot
C1. Rehearse the migration chain on a throwaway database first
Why. The migration history was rebuilt and the frozen-name map retired on the same
no-production-deployment grounds — the ordering of the pre-20260801T000000 block is
uncorrected by design
(backend/src/db/migration-order.ts), and the chain has only ever been applied to databases
that were free to be thrown away. The first production database is the first one that has to
keep its rows.
Do (engineer). On the exact commit that will be deployed, apply the whole chain to an empty
throwaway database — DATABASE_URL=…/b2b_rehearsal pnpm --filter backend run db:fresh. Never
run db:fresh or db:reset without an explicit DATABASE_URL: unprefixed they rebuild the
developer's own database.
Verify. The run completes with no ordering failure, and the resulting schema matches what
the release's backend-migrate container produces on the VPS.
C2. Do not run the demo seed
Why. The demo seed (endora demo seed) writes a whole shop — a catalogue, an
organisation, an administrator and a buyer — into the database it is pointed at. It no longer
truncates on the way in (that truncation moved into endora demo reset, which does), so what
it costs a production database is rows that are not the client's rather than the loss of ones
that are. It has a production guard —
ALLOW_DEV_SEED_IN_PRODUCTION — which deploy/compose.prod.yml used to defeat permanently in
a pre-armed seed service that deploy/README.md listed as a deployment step. That service
has since been removed and the seed taken out of the deployment procedure: there is now no way to
run it that does not involve an operator typing -e ALLOW_DEV_SEED_IN_PRODUCTION=true
themselves.
That closes the accident, not the decision. The seed is still reachable, and this step is still the place where an operator says no to it.
Do (operator + engineer). Run no seed. Load the client's real catalog through the Import/Export module instead — or, where the client keeps its catalogue in a PIM, through the PIM connector the deployment installs for it. A deployment starts with an empty catalogue on purpose.
Verify. No demo products, no demo organizations, no platform_admin account you did not
create yourself. select count(*) from products returns what the client's own import produced.
C3. Confirm what the platform seeded for itself
Why. Some reference data arrives without anybody asking: the country/currency/language
reconciler runs as a boot hook (packages/modules/dictionaries/src/backend/index.ts:201), and the
system-default sales channel is reconciled at boot rather than by a migration. If a boot hook
fails, the process exits — so a running backend is already evidence they ran. What is not
evidence is that the seeded values are the right ones for this client.
Do (operator). Open the Dictionary screen and confirm the countries the client trades with are present and active, and that the default country's default currency is right.
Verify. The address form on the storefront offers the client's country, and prices render in the client's currency.
D. Identity, roles and permissions
D1. Create the bootstrap administrator, then narrow it
Why. The only role the platform ever creates for you is platform_admin, holding the
wildcard * permission. Everything else is the client's own design.
Do (engineer, then operator).
cd /opt/b2b
docker compose --env-file .env -f compose.prod.yml run --rm backend \
pnpm exec tsx src/cli.ts admin_users create \
--email=… --password=… --first-name=… --last-name=…
Then, in the Admin UI, define the roles the client actually needs on /admin-roles, and stop
using the wildcard account for day-to-day work.
Verify. /admin-roles lists the client's roles, and at least one non-wildcard account can
do its job end to end.
D2. Grant customer_groups:read and customer_groups:write
Why. Customer-group management moved from price_lists to customer_accounts and gained
permission codes of its own. Before the move it was gated by catalog:write,
which was plainly wrong — a customer group is customer segmentation, not catalog data. The two
new codes are customer_groups:read and customer_groups:write
(packages/modules/customer_accounts/src/manifest.ts:188-189).
Nothing grants them automatically. A compatibility gate that would have accepted the old
catalog:write alongside the new codes was offered and deliberately refused: it would have kept
a wrong permission alive past the moment it stopped being right, for the benefit of nobody,
since there was no deployment to protect. The grant belongs here instead. The wildcard * role
is unaffected — it already passes every gate.
Do (operator). On /admin-roles, for every role that is not * and whose holder needs to
see or manage customer groups, tick both permissions (or one, if the role should only read).
Which roles need them:
| A role whose holder… | needs |
|---|---|
manages the customer-group list itself (/customer-groups) | customer_groups:read + customer_groups:write |
edits a customer and assigns their group — the picker in packages/modules/customers/src/admin/panels/ManagementPanels.tsx, which reads GET /api/v1/admin/customer-groups | customer_groups:read |
Those two, and no others. The promotion rule builder and the PWA push-audience builder also
show a group list, but each reads it through its own module's endpoint
(/api/v1/admin/promotions/rule-targets/customer-groups,
/api/v1/admin/pwa/rule-targets/customer-groups) behind that module's own read permission, so
they are unaffected by this grant. The price-list rule builder was the exception until
recently; it now reads its list behind price_lists:read, which is the subject of D3.
The same grant can be made over the API:
PUT /api/v1/admin/admin-roles/<code> with the role's full permission list including the new
codes.
Verify. Sign in as a holder of each edited role and confirm three things: the Customer
groups entry appears in the sidebar; ⌘K → "customer groups" offers the action (the palette
hides actions whose requiredPermission the operator lacks); and GET /api/v1/admin/customer-groups returns 200 rather than 403. A role you deliberately did not
grant must still get 403 — that is the other half of the proof.
D3. Grant price_lists:read and price_lists:write
Why. The price_lists module used to declare no permissions of its own: all 25 of
its admin routes were gated by catalog:write. A role granted catalog:write so somebody could
edit product descriptions could also create, edit and delete price lists — that is, change what
customers pay. Nobody chose that boundary; it was the side effect of a missing declaration. The
module now owns price_lists:read and price_lists:write
(packages/modules/price_lists/src/manifest.ts), split by what each route does rather than
mapped wholesale: reading a list, its product roster, its brackets, the display-mode overrides
and the rule-target pickers is :read; anything that persists is :write.
The same change closed the last surviving legacy gate:
GET /api/v1/admin/pricing/rule-targets/customer-groups answered on catalog:write, so a
catalogue editor could enumerate the client's customer groups. It now answers on
price_lists:read, matching its promotions and pwa twins.
Nothing grants the new codes automatically, and — as in D2 — a compatibility gate accepting
catalog:write alongside them was offered and refused, because it keeps a wrong permission
alive past the moment it stopped being right. A role holding only catalog:write therefore has
no pricing access at all: the sidebar entry disappears, /price-lists 403s, and the
Pricing tab on the product editor renders its error state instead of the linked price lists
(it reads GET /api/v1/admin/products/:productId/price-lists, now a price_lists:read route).
The wildcard * role is unaffected.
Do (operator). On /admin-roles, for every role that is not *, decide pricing explicitly:
| A role whose holder… | needs |
|---|---|
manages price lists, brackets, rules or display-mode overrides (/price-lists, /price-lists/:id, /price-lists/display-modes) | price_lists:read + price_lists:write |
| only needs to see a quoted price explained — reads price lists, or opens the Pricing tab on a product | price_lists:read |
works in another module's screen that offers price-list or currency pickers — they read GET /api/v1/admin/price-lists-engine and GET /api/v1/admin/pricing/rule-targets/currencies | price_lists:read, on top of that module's own permissions |
| edits catalogue content and must not change prices | neither — leave catalog:write as it is |
The last row is the point of the change: after this, catalog:write means catalogue content and
nothing else. Review every existing role that holds it and decide which of the first two rows,
if either, it also belongs in.
The same grant can be made over the API:
PUT /api/v1/admin/admin-roles/<code> with the role's full permission list including the new
codes.
Verify. Sign in as a holder of each edited role and confirm four things: the Price lists
entry appears in the sidebar; GET /api/v1/admin/price-lists-engine returns 200 rather than
403; a role granted only price_lists:read gets 403 from
POST /api/v1/admin/price-lists-engine, so the read/write split is real; and a role holding
catalog:write and neither pricing code gets 403 from GET /api/v1/admin/price-lists-engine
and from GET /api/v1/admin/pricing/rule-targets/customer-groups — that negative case is the
half that proves the boundary moved, not just widened.
D4. Turn on two-factor authentication for the Admin UI
Why. The MFA module is active by default, but every capability inside it ships off:
mfa.admin.totp_enabled and mfa.admin.totp_enforced both default to false
(packages/modules/mfa/src/manifest.ts:37-51). A deployment that changes nothing has
password-only admin access on a public domain.
Do (operator + engineer). Set MFA_SECRET_ENCRYPTION_KEY (B1), then allow admin 2FA,
enrol every administrator, and only then enforce it — enforcing before enrolment locks
everybody out.
Verify. A second sign-in attempt asks for the code, and an account with no enrolment is refused once enforcement is on.
E. Module activation
E1. Walk /platform/modules and decide each one
Why. A module's presence is the conjunction of platform availability and the operator's activation choice — and the second axis has a default. Of the core modules, 23 declare themselves non-deactivatable and the rest ship an operator activation control; every one of those controls defaults to on. Nothing about a fresh install expresses what this client bought. A module left on contributes its sidebar entry, palette actions, settings group, API surface and storefront elements whether or not anyone asked for it.
Do (operator). Go through /platform/modules once, with the client, and switch off what
they are not using. Switching off is non-destructive and reversible: it drops no data,
configuration, permissions or schema. Do not rely on the confirmation prompt to tell you what
a deactivation costs — today it only names the module
(admin/src/modules/platform/ModuleActivationControl.tsx:88). What breaks when a module goes
off is recorded in the deactivation-consequence ledger, which
pnpm --filter backend run check:port-dependencies builds; ask an engineer to read it for any
module the client is not obviously done with.
Verify. For each module switched off: its sidebar entry is gone, its palette actions are
gone, and its API answers 503 MODULE_DISABLED. For each left on, somebody can name why.
E2. Decide the marketing and analytics modules explicitly
Why. google_analytics, google_tag_manager, meta_ads and linkedin_ads are all active
by default. Each has a second, capability-level toggle that is off until configured, so nothing
is transmitted yet — but activation is what puts the screens and the consent-mode settings in
front of the operator, and whether the client wants third-party tracking at all is a decision
with legal weight in the EU.
Do (operator). Confirm per module: wanted or not. Where wanted, configure the measurement
ID and the require_consent toggle before the first visitor.
Verify. With the modules the client declined switched off, no third-party tag appears in the storefront's page source.
E3. Decide the AI assistant separately
Why. prompt_actions is active by default, though the assistant itself
(prompt_actions.enabled) is off and needs an LLM credential before it can do anything. Turning
it on means admin instructions and the data needed to resolve them leave the platform for a
third-party model provider. That is a data-processing decision, not a configuration one.
Do (operator). Decide with the client. If yes, register the LLM credential on
/credentials, set the prompt_actions.bulk_limit, and grant prompt_actions:use deliberately
rather than by inheritance.
Verify. If declined, the palette's prompt mode is absent. If accepted, the client has agreed in writing to the provider.
F. Business configuration before the first transaction
F1. Invoice seller identity and numbering
Why. The Invoices module is active by default and its seller identity is empty:
invoices.seller.tax_id defaults to '' and invoices.seller.company_data to {}
(packages/modules/invoices/src/manifest.ts:40-55). The numbering patterns default to
FV {seq}/{channel}/{YYYY}, PRO …, KOR … — a reasonable shape, and still a choice the
client's accountant has to confirm, because it is not comfortably changed once documents exist
under it. A valid tax id is also a precondition for KSeF serialization if the client uses it.
Do (operator). Fill the seller settings and confirm the three numbering patterns in Settings → Invoices before the first invoice is issued.
Verify. Issue one invoice against a test order and read the PDF: the seller block is the client's real legal identity and the number matches the agreed pattern.
F2. Order numbering, minimum order value and confirmation recipients
Why. orders.business_id.prefix and orders.business_id.suffix default to '',
orders.min_order_value to 0, and orders.confirmation_recipients to []
(packages/modules/orders/src/manifest.ts). The last one is the quiet one: with an empty list,
nobody at the client is notified when an order is placed.
Do (operator). Set the order-number affixes before the first order, the minimum order value to the client's commercial rule, and at least one internal confirmation recipient.
Verify. Place a test order: its number carries the agreed affixes and the confirmation lands in the client's internal inbox.
F3. Taxes, delivery and payment methods
Why. No tax rate is seeded, and no delivery or payment method is configured for this client:
the delivery and payment modules only reconcile a row per gateway adapter that happens to be
installed (their installHooks), which is a placeholder, not a commercial decision. An order can
be placed with all three wrong long before anybody notices.
Do (operator). Configure the VAT rates the client charges, the delivery methods with their per-channel availability, and the payment methods.
Verify. A test checkout shows the expected tax line, offers exactly the delivery and payment options the client expects, and totals to the number the client's own system would produce.
F4. Switch each payment gateway from sandbox to production
Why. A payment gateway module is installed separately from the platform, and a gateway
module normally ships with its environment setting at sandbox and holds separate credentials
per environment. A deployment that goes live in sandbox takes no money; one that forgets to
register the production callback URL takes money and never confirms the order. A client that
takes no online payment — bank transfer, or a credit limit with deferred terms — has no gateway
and skips this item.
Do (operator + engineer). For each gateway the client uses: enter the production
credentials, flip the environment setting to production, and register the callback URL —
built on PUBLIC_API_BASE_URL (B2) — in the provider's own portal. The gateway module's own
documentation names the callback path and any endpoint the provider has to enable on request.
Verify. One real low-value transaction per gateway, end to end, and confirm the order reaches the paid state from the provider's callback — not from a manual status change.
F5. KSeF, if the client invoices in Poland
Why. Endora submits invoices to KSeF through a KSeF submission module, which is available
separately. Its integration ships switched off and pointed at KSeF's test environment, which is
the right default — a misconfigured production submission is legally binding. Going live is
therefore a deliberate act. A client whose accounting vendor submits to KSeF instead sets the
invoice ledger's KSeF routing to vendor.
Do (operator). For native submission, configure the KSeF module as its own documentation
describes: credentials, a connection check in test, then the switch to prod with the
integration enabled. For vendor submission, set the routing on the invoice ledger's Routing tab.
Verify. One invoice submitted in test and accepted, before the environment is switched —
or, for vendor submission, one invoice carrying the KSeF number the vendor recorded.
G. Operations that must exist on day one
G1. Backups, including the assets volume
Why. deploy/README.md describes a pg_dump cron as recommended and covers Postgres
only. The Assets Library's local-filesystem adapter writes uploaded files into the
backend-assets volume (deploy/compose.prod.yml), and nothing backs that up. A restored
database with no files is a catalog of broken images and unreachable invoice PDFs.
Do (engineer). Install the off-box pg_dump cron, add the assets volume to it, and — the
part that is usually skipped — restore both into a scratch environment once, before go-live.
Verify. A restore rehearsal produces a working storefront with images.
G2. Build the search index after the first catalog load
Why. The search index is maintained incrementally on write. Data loaded before the index existed, or loaded by a path that bypassed the events, is simply not there — the storefront search returns nothing and no error.
Do (engineer). After the client's catalog import completes, run the re-index. On the VPS:
docker compose --env-file .env -f compose.prod.yml run --rm backend \
pnpm exec tsx src/cli.ts search reindex
(the same invocation the search:reindex package script makes — src/cli.ts is the host
binary that runs the commands modules declare in their manifest.ts; --list prints every
one this instance offers). See docs/docs/modules/search.md.
Verify. Search for a product you know exists and find it; compare the indexed document count against the product count.
G3. Tell the backend which proxy may name the client's IP address
Why. The client's address reaches the application only through
X-Forwarded-For, and the backend believes that header only from a hop it has been
told to trust — otherwise request.ip is the host nginx for every request. Three
consequences: the per-IP rate limit (1000/min) becomes one shared bucket for the whole
internet; the IP recorded on security-relevant audit rows — MFA events, admin
impersonation, prompt-action runs — is the proxy, not the actor; and the public
product-feed rate-limit key collapses for unauthenticated callers. This used to be an
open question with no answer in the code; the answer is now a variable.
Do (engineer). Confirm the host nginx sets X-Forwarded-For and X-Forwarded-Proto
(the template in deploy/nginx.example.conf already does, with
$proxy_add_x_forwarded_for), then set in deploy/.env on the VPS:
TRUSTED_PROXY_HOPS=1
One hop, because exactly one proxy sits between the internet and the backend
container. Add one per additional proxy — a CDN in front of the host nginx makes it 2 —
and count it wrong in the high direction only at your peril: each extra hop is one
more X-Forwarded-For entry the client itself could have written. Where the proxy's
address is fixed and known, TRUSTED_PROXY_ADDRESSES takes IPs, CIDR ranges or the
named ranges loopback / linklocal / uniquelocal instead; set one variable or the
other, never both. There is deliberately no value meaning "trust any hop", and the
backend refuses to boot on a value it cannot parse rather than falling back to trusting
nothing — a silent fallback is exactly the state this item exists to end.
Verify. After the stack restarts, sign in from a known external address and read back
the MFA or impersonation audit row: the address recorded must be yours, not the proxy's.
A quick negative check is curl -H 'X-Forwarded-For: 1.2.3.4' https://<API_DOMAIN>/...
from outside — with one trusted hop, the forged entry is ignored and the address logged is
still yours, because nginx appends its own view of the peer after it.
Deliberately not on this list
Each of these was considered and left off, with the reason. If a reason stops holding, the item moves up.
- Provisioning the VPS, DNS, TLS, the registry and the compose stack. Covered by
deploy/README.md, which is the procedure this page assumes has been followed. Duplicating it is how the two drift. - The coordinated developer-database reset. It is a developer-workstation procedure. The first production database starts empty and applies the chain once; C1 is what covers it.
- The price-list migration report — the report that flags rows needing a real per-currency value before go-live. It describes migrating a pre-existing deployment's legacy unit prices. A first deployment has no legacy prices to migrate. It becomes a real item the first time a client is migrated onto the platform from something else.
- Customer deletion retention (
customers.deletion_retention_days, default 365) and presence freshness. The default is safe and does not bite for a year, and the setting is editable at any time with no data consequence. It belongs on a GDPR review, not a go-live gate. - Per-module integration credentials for modules the client is not using — PIM and ERP connectors, product feeds, newsletter providers, the marketing pixels. There are dozens of settings whose default is an empty string; every one of them is inert until its module's capability is switched on. E1 is the item that decides which of those exist at all; listing each credential here would be a settings dump, not a checklist.
- Meilisearch, Redis and Postgres tuning. Capacity work, not correctness work, and the
single-VPS sizing note in
deploy/README.mdcovers the floor. - A module rename or a stuck lifecycle lock. Incident procedures, not go-live steps; the
runbook is
docs/docs/operations/runbooks/module-lifecycle-stuck-lock.md. - Client-specific integrations — the ERP or WMS connection, API keys, webhook subscriptions. Real work, and it is project scoping rather than a platform go-live gate: nothing in the platform is wrong until the client asks for one.
- Anything a static check already refuses. If CI can fail on it, it is not an item here — that is the whole design of the repository's check inventory.
When the no-deployment licence closes
On the day the first deployment carries a client's data, the "nothing can break yet" licence stops applying. From then on: renaming an applied migration class needs a rename map again, a permission gate cannot change without a grant path, and a contract change needs the usual versioning discipline. This page is where the consequences were paid.