Part 4 — Running It in Production
O Hosting, Domains and Environments
This chapter covers the physical and administrative reality of your ERP: which machines run it, which name a customer types into a browser, which certificate proves that name is really you, and how you keep four separate copies of the system straight without leaking real customer data into a test. It ends with a costed monthly bill for one, ten and fifty tenants, and a go-live checklist you can work through line by line. Every price and limit below was checked against the vendor's own page on 25 July 2026. Cloud prices move several times a year, so re-check anything you are about to budget on.
In this chapter
- What you need to know first
- Domain strategy: four shapes, and which one to pick
- Tenant resolution from hostname in Next.js
- DNS, taught from zero
- TLS and certificates
- Identity, sessions and single sign-on
- Environments
- Backups, restores, RPO and RTO
- Hosting options compared
- Email and deliverability
- File and document storage
- Observability and status
- Cost modeling
- Go-live checklist for the domain and hosting layer
What you need to know first
Everything in this chapter sits on top of a handful of ideas. If any of them are fuzzy, the rest will feel like magic words. So here they are from zero.
Machines, addresses and names
A server is a computer that waits for requests and answers them. Your ERP is a program running on one or more servers. A client is whatever makes the request: a browser on a sales rep's laptop, the sync client on an iPad at a trade show, or a warehouse system calling your API.
An IP address is the number that identifies a machine on the internet. IPv4 addresses look like 76.76.21.21 — four numbers, each 0 to 255. IPv6 addresses are longer and written in hexadecimal, like 2606:4700::6810:85e5. Every request eventually goes to an IP address. People cannot remember IP addresses, so we invented domain names.
A domain name is a human-readable label like kestrel.com. DNS — the Domain Name System — is the global phone book that turns a domain name into an IP address. You do not own a domain forever; you rent it from a registrar (Namecheap, Cloudflare Registrar, Porkbun and others) on an annual basis, and if you forget to renew it, your ERP disappears from the internet.
A hostname is the full name in a URL. In https://kestrel.yourerp.com/orders/1234, the hostname is kestrel.yourerp.com. The part before the first dot (kestrel) is a subdomain label; yourerp.com is the apex or root domain. This distinction matters more than you would guess, and gets a whole section below.
HTTPS, origins and cookies
HTTP is the protocol browsers use to talk to servers. HTTPS is HTTP wrapped in TLS (Transport Layer Security, the successor to SSL), which encrypts the conversation and proves the server is who it claims to be. The proof is a certificate, a small signed file issued by a certificate authority (CA) that your browser already trusts. Modern browsers show a warning, or refuse to load the page at all, if a site is not on HTTPS. Your ERP runs on HTTPS everywhere, in every environment.
An origin is the combination of scheme, hostname and port — https://kestrel.yourerp.com. Browsers enforce security rules per origin. Cookies, local storage and CORS (cross-origin resource sharing, the browser rule that decides whether a page loaded from one origin may call an API on a different one) all key off it. Two hostnames are two origins even if they run the same code, which is why domain strategy affects login behavior.
A cookie is a small piece of data a server asks the browser to store and send back on every subsequent request to that site. Sessions are usually cookies. Cookies have a Domain attribute: a cookie set for yourerp.com is sent to every subdomain of it; a cookie set for kestrel.yourerp.com is sent only there. That single rule drives most of the trade-offs later in this chapter.
Proxies, containers and deploys
A reverse proxy is a server that sits in front of your application, accepts every incoming request, and forwards it on. It is where TLS is usually terminated, where redirects and security headers are added, and where traffic is spread across several copies of the app. Nginx, Caddy and Traefik are reverse proxies. On a managed platform like Vercel or Fly.io, the platform runs one for you and you never see it.
A container is a packaged copy of your application plus the operating-system libraries it needs, built once and run identically anywhere. Docker is the usual tool for building and running them. The point is repeatability: the container that passed your tests is byte-for-byte the container that runs in production, so "it worked on my machine" stops being an explanation.
Deploying means taking the code from your laptop or from Git and making it the thing that answers requests. An environment is one complete running copy of the system: its own URL, its own database, its own secrets. You will run four of them (local, preview, staging, production) and confusing them is one of the most expensive mistakes available to you.
An environment variable is a named value handed to a program when it starts — a database password, an API key, the root domain. They live outside the code so the same code can run in every environment. A secret is an environment variable whose leak would hurt you.
Regions, latency and egress
A region is a physical location where a cloud provider runs machines: iad1 (Washington, D.C.), fra1 (Frankfurt), syd1 (Sydney).
Latency is the round-trip time for a message. Light in fiber travels roughly 200,000 km per second, so New York to London — about 5,600 km — cannot beat about 56 ms round trip, and in practice runs 70–90 ms. That physical floor is why your app server and your database must live in the same region.
A CDN (content delivery network) is a fleet of caching servers spread worldwide that serve copies of your static files from close to the user. Vercel, Cloudflare and Fastly are CDNs with extra features bolted on.
Egress is data leaving a provider's network toward your users — page HTML, images, PDF downloads, API responses. Almost every host meters and bills it, so it shows up repeatedly in the price tables later.
Tenants and the job of this chapter
A tenant, as established in the multi-tenancy chapter, is one customer's isolated slice of your ERP: one brand, its own orders, its own users. Multi-tenancy means many tenants share one running system, kept apart by a tenant_id column and Postgres row-level security (RLS). This chapter's job is to get the right tenant_id into that security context from nothing but a hostname and a login.
Treat the hostname as a routing hint only. Deciding which tenant's data to look at can start from the URL. Deciding whether this user may see it must come from the verified session and a membership check in the database. Confuse those two jobs and you have built a data breach with a nice URL.
Domain strategy: four shapes, and which one to pick
Before you buy anything, decide what a customer types. There are four workable answers, and they differ in ways that will follow you for years.
Option 1 — one shared app, tenant chosen after login
Everyone goes to app.yourerp.com. They log in. If their account belongs to more than one tenant, they pick one from a menu; if it belongs to exactly one, you select it silently. The chosen tenant lives in the session.
This is the simplest thing that works. One hostname means one TLS certificate, one cookie domain, one set of DNS records, one place for login redirect URLs, one CORS policy. Onboarding a new customer requires zero infrastructure work — you insert a row. Support is easy because every screenshot a customer sends you has the same URL.
The costs are small, and the first of them is cosmetic. The URL carries no branding, so an enterprise buyer may sniff at it.
Because one browser session holds one tenant selection, a user who works across two tenants has to switch back and forth, and if you are careless with caching they will see stale data from the wrong tenant. Deep links (the URL in an order-confirmation email) must encode the tenant, or the link opens in whatever tenant the recipient last selected. Fix that by making tenant part of the path — /t/kestrel/orders/1234, so links are unambiguous.
Option 2 — a subdomain per customer on your domain
Each tenant gets kestrel.yourerp.com, meridian.yourerp.com, and so on. You add a wildcard DNS record and a wildcard certificate once, and new tenants need no DNS work at all.
The hostname now identifies the tenant before any code runs, so your routing layer can resolve tenant on the very first request, and links in emails are unambiguous. It reads as more professional than a shared URL, and it costs almost nothing extra.
The subtleties are around cookies and sessions. If you set the session cookie on .yourerp.com (note the leading dot), it is shared across all tenant subdomains, so a single login works everywhere — convenient, but it means the browser sends the same session token to every tenant's hostname, and your server must still check membership per request.
If you set the cookie on the exact subdomain instead, sessions are isolated but a user with two tenants logs in twice. Both are defensible. Pick cookie-per-subdomain if you have any customers who share a device with a competitor's staff (it happens at showrooms), otherwise pick the shared parent cookie and enforce membership server-side.
The other subtlety: on Vercel, a wildcard domain requires that the domain use Vercel's nameservers, because Vercel needs to answer DNS challenges to issue wildcard certificates. Once the nameservers are switched, you add the apex domain and then the wildcard, and every tenant.yourerp.com resolves without further work (Vercel multi-tenant quickstart, page last updated 26 June 2026). If you want to keep DNS at Cloudflare, you can still do subdomain-per-tenant, but you add each subdomain to the project individually rather than using a wildcard.
Option 3 — a branded subdomain on the customer's own domain
The customer creates erp.kestrel.com in their own DNS and points it at you with a CNAME record. To their staff, the ERP looks like part of the company. To their IT team, it is a third-party service they have consciously delegated a name to.
This is the option that generates the most operational work. Each new hostname needs its own certificate, issued on demand after the customer's DNS is correct. Each one is a separate origin, so each needs its own session cookie, its own login redirect URI registered with any identity provider, and its own entry in your allow-lists.
When the customer's IT team changes DNS providers and forgets to copy the CNAME, your ERP goes down for that customer and you find out from an angry phone call.
Platforms make it tolerable. Vercel exposes a Domains API so you can add a customer hostname programmatically and it issues the certificate automatically.
Custom domains per project are capped at 50 on the Hobby plan and are unlimited on Pro and Enterprise, with an anti-abuse soft limit of 100,000 domains per project on Pro; the API allows 100 domain additions, 50 verifications and 100 removals per hour per team (Vercel multi-tenant limits, page last updated 26 June 2026).
Cloudflare for SaaS does the same job from the DNS side: 100 custom hostnames are included on every plan, additional ones cost $0.10 each per month, and the ceiling is 50,000 on Free, Pro and Business (retrieved 25 July 2026).
Option 4 — a full custom domain per customer
The customer uses kestrelwholesale.app or similar, an entire domain that exists only to point at your ERP. Everything in option 3 applies, plus you now care about the apex-CNAME problem (below), about who renews the domain, and about what happens when they let it lapse. Reserve this for a customer paying enough that a dedicated support conversation is worth it.
The trade-offs side by side
| Concern | Shared app.yourerp.com | Subdomain per tenant | Customer CNAME erp.kestrel.com | Full custom domain |
|---|---|---|---|---|
| Cookies / sessions | One origin, one cookie. Simplest possible. | Share on .yourerp.com or isolate per subdomain — your choice. | Separate origin per customer; separate login per customer; no cross-domain single sign-on without extra work. | Same as CNAME, plus apex cookie rules. |
| TLS certificates | One certificate, renewed automatically forever. | One wildcard certificate; needs the DNS-01 challenge, so your platform must control DNS. | One certificate per hostname, issued on demand after the customer's DNS is right. Failure mode: certificate stuck pending. | Same, plus apex and www both need covering. |
| Tenant resolution | From session after login. First request cannot know the tenant. | From hostname, before any code runs. Cleanest. | From hostname via a database lookup of registered domains. Needs a cache. | Same as CNAME. |
| RLS tenant context | Set from session claim only. | Set from hostname, cross-checked against session membership. | Set from domain lookup, cross-checked against session membership. | Same as CNAME. |
| Email links | Must embed tenant in the path or the link is ambiguous. | Naturally unambiguous. | Unambiguous and branded — best perceived quality. | Best perceived quality. |
| Enterprise single sign-on | One callback URL; identity-provider config is trivial. | One callback URL if you terminate login on a shared hostname and redirect. | One callback URL per customer domain unless you centralize; more config per customer. | Most config per customer. |
| Passkeys / WebAuthn | One credential scope for everyone. | Register against the parent domain and one passkey works on every tenant subdomain. | A passkey registered here works nowhere else. Users need one per domain. | Same as CNAME. |
| Support burden | Lowest. | Low. You control DNS. | High. You depend on someone else's DNS team. | Highest. |
| Setup time per new customer | Seconds (insert a row). | Seconds. | Hours to days (waiting on their IT). | Days. |
Start with subdomain per tenant — kestrel.yourerp.com, and build the hostname-to-tenant lookup as a database table from day one. It costs nothing extra today (one wildcard certificate, one wildcard DNS record) but the code path is identical to the one you need later for customer-branded domains. When a customer eventually asks for erp.kestrel.com, you add one row to tenant_domain, call the platform's domain API, and the application does not change. Choosing the shared-app option first means rewriting tenant resolution when you grow; choosing customer domains first means carrying support burden before you have revenue to pay for it.
Tenant resolution from hostname in Next.js
Next.js runs a piece of code before every matching request. Through version 15 that file is middleware.ts and the exported function is middleware. In Next.js 16 the convention was renamed: the file is proxy.ts, the function is proxy, and it defaults to the Node.js runtime (Next.js docs, v16.2.11, page last updated 13 May 2026).
There is a codemod — a script that edits your source files for you — that renames both: npx @next/codemod@canary middleware-to-proxy .. The examples below use the Next.js 16 names; if you are on 15, substitute the old ones and nothing else changes.
// proxy.ts (Next.js 16+. On Next.js 15 name this middleware.ts
// and export a function called `middleware`.)
import { NextResponse, type NextRequest } from 'next/server'
const ROOT = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'yourerp.com'
// Labels we will never sell to a tenant. Anything here that a
// customer could claim becomes a support incident or worse.
const RESERVED = new Set([
'www', 'app', 'api', 'admin', 'status', 'docs', 'help',
'staging', 'preview', 'cdn', 'assets', 'blob', 'mail',
'send', 'smtp', 'mx', 'ns1', 'ns2', 'autodiscover',
])
export const config = {
matcher: [
// Everything except static assets and the public API.
'/((?!api/public|_next/static|_next/image|favicon.ico).*)',
],
}
export function proxy(req: NextRequest) {
const host = (req.headers.get('host') ?? '')
.toLowerCase()
.replace(/:\d+$/, '') // strip :3000 in local dev
// Strip any client-supplied copy of our internal headers BEFORE
// we branch. A client can send any header it likes. If we forget
// this on even one code path, x-tenant-slug becomes forgeable.
const headers = new Headers(req.headers)
headers.delete('x-tenant-slug')
headers.delete('x-tenant-host')
const resolution = resolveHost(host)
if (resolution.kind === 'invalid') {
return new NextResponse('Bad request', { status: 400 })
}
if (resolution.kind === 'marketing') {
// Still forward the STRIPPED headers, never the originals.
return NextResponse.next({ request: { headers } })
}
if (resolution.kind === 'subdomain') {
headers.set('x-tenant-slug', resolution.slug)
}
headers.set('x-tenant-host', host)
return NextResponse.next({ request: { headers } })
}
type Resolution =
| { kind: 'invalid' }
| { kind: 'marketing' }
| { kind: 'subdomain'; slug: string }
| { kind: 'custom' }
function resolveHost(host: string): Resolution {
// Reject anything that is not a plausible hostname before it can
// become a cache key or reach the database.
if (!/^[a-z0-9.-]{1,253}$/.test(host)) return { kind: 'invalid' }
// Local development: kestrel.localhost:3000
if (host === 'localhost') return { kind: 'marketing' }
if (host.endsWith('.localhost')) {
return { kind: 'subdomain', slug: host.split('.')[0] }
}
if (host === ROOT || host === `www.${ROOT}`) {
return { kind: 'marketing' }
}
if (host.endsWith(`.${ROOT}`)) {
const label = host.slice(0, -(ROOT.length + 1))
if (label.includes('.')) return { kind: 'marketing' }
if (RESERVED.has(label)) return { kind: 'marketing' }
return { kind: 'subdomain', slug: label }
}
// Anything else is a bring-your-own domain. We do not hit the
// database here; the request handler resolves it with a cache.
return { kind: 'custom' }
}
What this does: it reads the Host header the browser sent, lowercases it and strips any port number so kestrel.localhost:3000 behaves like production. It rejects anything that is not a plausible hostname with a 400. It then classifies the hostname into your marketing site, a tenant subdomain, or an unknown domain that must be a customer's own.
Note the order: it deletes x-tenant-slug and x-tenant-host before the branching, so even the marketing path cannot pass a forged header through, then sets the trusted values. Downstream server code reads the header instead of re-parsing the URL.
To check it, visit kestrel.localhost:3000 in dev and log the request headers: handlers should receive x-tenant-slug: kestrel. Visiting localhost:3000 should give them neither header, even if you send one by hand with curl -H 'x-tenant-slug: evil'.
Two limits apply before you lean on this file. First, it deliberately keeps away from the database: this code runs on every request, and a database round trip here taxes every page load. Custom domains are resolved in the request handler instead, from a table, behind a short cache.
Second, a Server Function — a function you mark with 'use server' so the browser can call it directly — does not get its own URL. The Next.js documentation puts it this way: "Server Functions are not separate routes in this chain. They are handled as POST requests to the route where they are used, so a Proxy matcher that excludes a path will also skip Server Function calls on that path."
A matcher change or a refactor that moves a Server Function can therefore remove proxy coverage silently. Never treat the proxy as your only authorization check.
The hostname-to-tenant table
-- Every hostname we are willing to answer on, and who owns it.
create table tenant_domain (
hostname text primary key,
tenant_id uuid not null references tenant(id) on delete cascade,
is_primary boolean not null default false,
verified_at timestamptz,
cert_status text not null default 'pending'
check (cert_status in
('pending','issued','failed','revoked')),
created_at timestamptz not null default now()
);
-- One primary hostname per tenant: the one used in emails.
create unique index tenant_domain_one_primary
on tenant_domain (tenant_id)
where is_primary;
-- Subdomains get seeded automatically when a tenant is created.
insert into tenant_domain (hostname, tenant_id, is_primary, verified_at,
cert_status)
values ('kestrel.yourerp.com', '…uuid…', true, now(), 'issued');
This table is the single source of truth for "which customer does this hostname belong to". The index is a partial unique index: the where is_primary clause means the uniqueness rule applies only to rows where is_primary is true, so a tenant may have many hostnames but exactly one primary.
That primary is what you put in outbound email links, so a customer never receives a link to a domain they have stopped using. cert_status lets your support screen show why a newly added custom domain is not working yet.
When you run this you should see the index created without error. If it fails with a uniqueness violation, you already have two primary rows for one tenant and need to fix the data first.
Setting the RLS tenant context
// packages/db/tenant-context.ts
import 'server-only'
import { headers } from 'next/headers'
import { pool } from './pool' // node-postgres Pool
import type { PoolClient } from 'pg'
type Tenant = { id: string; slug: string }
const TTL_MS = 60_000
const MAX_ENTRIES = 5_000
// Bounded, and it caches misses as well as hits. Without the
// bound, junk Host headers grow the map forever; without negative
// caching, they hit Postgres on every single request.
const cache = new Map<string, { t: Tenant | null; exp: number }>()
function remember(host: string, t: Tenant | null) {
if (cache.size >= MAX_ENTRIES) cache.clear()
cache.set(host, { t, exp: Date.now() + TTL_MS })
}
async function tenantForHost(host: string): Promise<Tenant> {
const hit = cache.get(host)
if (hit && hit.exp > Date.now()) {
if (!hit.t) throw new UnknownTenantError(host)
return hit.t
}
const { rows } = await pool.query<Tenant>(
`select t.id, t.slug
from tenant_domain d
join tenant t on t.id = d.tenant_id
where d.hostname = $1
and d.verified_at is not null
and t.status = 'active'`,
[host],
)
if (rows.length === 0) {
remember(host, null)
throw new UnknownTenantError(host)
}
remember(host, rows[0])
return rows[0]
}
/**
* Runs `fn` inside a transaction with the RLS tenant context set.
* Two independent facts must agree: the hostname says which tenant,
* the session says which tenants this user may enter.
*/
export async function withTenant<T>(
fn: (client: PoolClient, tenant: Tenant) => Promise<T>,
): Promise<T> {
const h = await headers()
const host = h.get('x-tenant-host')
if (!host) throw new Error('proxy did not set x-tenant-host')
const tenant = await tenantForHost(host)
const session = await requireSession() // your auth layer
if (!session.memberships.some((m) => m.tenantId === tenant.id)) {
throw new ForbiddenError(
`user ${session.userId} is not a member of ${tenant.slug}`,
)
}
const client = await pool.connect()
try {
await client.query('begin')
// `true` = transaction-local. It is reset on commit/rollback,
// so a pooled connection never leaks context to the next request.
await client.query(
`select set_config('app.tenant_id', $1, true),
set_config('app.user_id', $2, true)`,
[tenant.id, session.userId],
)
const out = await fn(client, tenant)
await client.query('commit')
return out
} catch (e) {
await client.query('rollback')
throw e
} finally {
client.release()
}
}
This is the join between this chapter and the multi-tenancy chapter. The hostname tells you which tenant the request is about. The session tells you which tenants the user is allowed into. Only when both agree do you open a transaction and set app.tenant_id, which is the setting your RLS policies read. (UnknownTenantError, ForbiddenError and requireSession are your own code, not library functions.)
A connection pool keeps a small set of open database connections and lends them out request by request, because opening a fresh one costs several round trips. That reuse is why the third argument true on set_config matters: it makes the setting local to the transaction, so the next request to borrow that connection cannot inherit the previous tenant's context.
The one-minute cache keeps a hostname lookup out of every single request; a domain change therefore takes up to a minute to appear, which is a fair trade. To test it, add a hostname to tenant_domain without setting verified_at and confirm requests to it raise UnknownTenantError rather than serving a page.
-- The reader side, from the multi-tenancy chapter, for completeness.
create schema if not exists app;
create or replace function app.current_tenant_id() returns uuid
language sql stable as $$
select nullif(current_setting('app.tenant_id', true), '')::uuid
$$;
alter table sales_order enable row level security;
alter table sales_order force row level security; -- applies to owner too
create policy sales_order_tenant_isolation on sales_order
using (tenant_id = app.current_tenant_id())
with check (tenant_id = app.current_tenant_id());
force row level security is the line people forget. Without it, the table's owning role bypasses the policy, and your application often connects as the owner, so isolation silently does nothing. After running this, try a query with no app.tenant_id set: it should return zero rows, not an error and not everything. That "returns nothing when unset" behavior is what makes the fail-safe direction correct.
Headers set by the proxy are only trustworthy because the proxy deleted the incoming copy first, on every path. If you deploy the app anywhere the proxy can be bypassed — a second project pointed at the same code, a direct call to an internal URL, a Server Function on a route your matcher excludes, a self-hosted Node server behind a misconfigured reverse proxy — then x-tenant-slug becomes attacker-controlled. Keep the membership check in withTenant even though it looks redundant. It is the thing that holds when the routing layer does not.
DNS, taught from zero
DNS is a distributed database whose only job is answering "what is the value of record type X for name Y". Understanding four things — the resolution chain, the record types, TTL, and the apex problem — is enough to run production. One more piece of vocabulary: a zone is the complete set of DNS records for one domain, and the zone apex is the bare domain name at the top of it.
The resolution chain
When a browser needs kestrel.yourerp.com, it asks a recursive resolver (usually your ISP's, or 1.1.1.1, or 8.8.8.8). The resolver, if it has nothing cached, asks a root nameserver "who handles .com?", then asks the .com TLD nameserver — TLD meaning top-level domain, the last part of the name — "who handles yourerp.com?", and gets back the names of your authoritative nameservers. Then it asks those directly for the record. Whatever they say is the truth.
Nameservers are therefore the top-level switch. Your registrar stores which nameservers are authoritative for your domain; those nameservers store the actual records. Registrar and DNS host are often the same company but do not have to be. Moving DNS hosting means changing the nameserver list at the registrar — a change Vercel's own documentation says takes 24 to 48 hours to propagate globally and, unlike a record change, cannot be sped up by lowering a TTL you no longer control.
The record types you will actually use
| Type | What it holds | Where you use it in an ERP |
|---|---|---|
NS | Names of the authoritative nameservers for a zone. | Set at the registrar. Changing it moves DNS hosting. |
A | An IPv4 address. | Apex domain pointing at your host. Vercel gives you a specific IP to use. |
AAAA | An IPv6 address. | Same as A but IPv6. Vercel's DNS documentation states plainly that "IPv6 is not supported on Vercel", so you omit it there. |
CNAME | "This name is an alias for that other name." Resolution restarts at the target. | Every subdomain: app, kestrel, a customer's erp.kestrel.com. Cannot exist at the apex. |
ALIAS / ANAME / CNAME flattening | A provider-specific record that behaves like a CNAME but returns A/AAAA records, so it is legal at the apex. | Pointing a bare kestrel.com at a host that only gives you a hostname. |
TXT | Arbitrary text. | Domain-ownership verification, SPF, DKIM, DMARC, ACME _acme-challenge records. |
MX | Mail exchanger, which server receives email for this domain, with a priority number. | Your inbound mail (Google Workspace, Fastmail). Sending does not use MX; receiving does. |
CAA | Which certificate authorities may issue certificates for this domain. | Locking issuance to Let's Encrypt. Vercel adds a Let's Encrypt CAA record at the zone apex automatically. |
SOA | "Start of authority" — administrative facts about the zone, such as the primary nameserver and how long other servers should cache a negative answer. | Created for you. You rarely edit it, but its presence at the apex is part of why a CNAME cannot live there. |
SRV | Host and port for a named service. | Rare in an ERP. Some SIP/XMPP integrations want it. |
HTTPS (RFC 9460) | CNAME-like behavior plus protocol hints, legal at the apex. | Newer; Vercel's own docs note not all clients support it. Treat it as a bonus. |
TTL, and why you lower it before a migration
TTL (time to live) is a number of seconds attached to every record. It tells resolvers how long they may cache the answer before asking again. A TTL of 3600 means that after you change a record, some users keep hitting the old value for up to an hour. Vercel's DNS defaults to 60 seconds, with a minimum of 30 and a maximum of 86,400 (Vercel DNS docs, page last updated 8 June 2026).
The migration procedure is always the same. At least 24 hours before you plan to move — Vercel's own guidance says about 24 hours, and 48 is safer — lower the TTL on the records you will change to 60 seconds. Wait a full old-TTL period so the long-cached copies expire everywhere. Then make the change. Now the world converges within a minute instead of a day. After you are satisfied, raise the TTL back to something like 3600 so you are not paying a DNS lookup on every cold visit.
# What does a normal resolver hand back? In dig's answer section
# the SECOND column is the TTL: for a cached answer it is the
# seconds remaining in that resolver's cache, not your record's
# configured TTL. Ask an authoritative server for the real value.
dig +noall +answer app.yourerp.com
# Ask the authoritative nameserver directly (no cache in the way).
dig +noall +answer @ns1.vercel-dns.com app.yourerp.com
# Which nameservers are authoritative right now?
dig +short NS yourerp.com
# Follow the whole chain: root, then TLD, then authoritative.
dig +trace app.yourerp.com
# Check a record type by name.
dig +short TXT _dmarc.yourerp.com
dig +short CAA yourerp.com
dig +short MX yourerp.com
dig is the tool for every DNS question. Its answer section prints five columns: name, TTL, class (IN), type, then value, so app.yourerp.com. 47 IN CNAME … means 47 seconds of cache life left, not a 47-second record.
The first command shows what a normal user's resolver would give you. The second bypasses caching by asking your authoritative server directly, which is how you tell "I have not made the change yet" apart from "I made the change and the world has not caught up". dig +trace prints each hop of the chain, which is how you diagnose a domain whose nameservers at the registrar do not match the ones you think you are editing.
All of these are read-only and safe to run against anyone's domain. On macOS dig ships with the system; on Ubuntu install it with sudo apt install dnsutils.
Apex versus subdomain, and the CNAME-at-apex problem
The apex (also called the root or the naked domain) is yourerp.com with nothing in front. A subdomain is anything with a label in front: app.yourerp.com.
The DNS specification says a name that has a CNAME record may not have any other records. The apex is required to have NS records and normally has SOA and MX records too. Therefore a CNAME at the apex is illegal. If your host only tells you "point at a CNAME target", you cannot do that at the apex with a plain CNAME.
Three ways out:
- Use an A record. Your host gives you a stable IP address. Vercel does this — the dashboard shows the exact value to use, and their DNS documentation uses
76.76.21.21as the example. Copy the value the dashboard shows you rather than one from a blog post; providers do rotate these. - Use ALIAS/ANAME or CNAME flattening. Cloudflare, Route 53, DNSimple and others implement a synthetic record type: you configure what looks like a CNAME at the apex, and the nameserver resolves the target itself and hands back A/AAAA records. Cloudflare calls this CNAME flattening. Two caveats from their documentation: turning flattening on for all CNAMEs can break third-party verification, "since the CNAME record itself will not be returned directly"; and "if the final CNAME target has no A/AAAA records (a dangling CNAME), CNAME flattening returns an empty response (NODATA)", which looks exactly like a propagation failure.
- Do not use the apex. Redirect
yourerp.comtowww.yourerp.comorapp.yourerp.comand put the application on the subdomain. This is the least fragile option and the one to prefer for the app itself; keep the apex for the marketing site.
There is a second reason beyond CNAMEs. Cookies set on an apex domain are sent to every subdomain of it, including any you later hand to a third party (a status page, a docs site, a marketing landing-page tool). Cookies set on app.yourerp.com are not. Running the ERP on a subdomain shrinks the blast radius — the set of other systems a single leaked cookie could reach, and it costs you nothing.
The exact records for a Vercel-hosted app plus a customer CNAME
Here is a complete zone for yourerp.com, hosted on Vercel, sending email through Resend, receiving email through Google Workspace. Values marked from dashboard are per-project and you must copy them from the vendor UI rather than from this page.
| Name (host) | Type | Value | TTL | Purpose |
|---|---|---|---|---|
@ (apex) | A | from dashboard (example in Vercel's DNS docs: 76.76.21.21) | 3600 | Marketing site at yourerp.com |
www | CNAME | from dashboard; Vercel's docs show the general form cname.vercel-dns-0.com, and newer projects are given a unique per-project target | 3600 | Points at Vercel; the redirect to the apex is configured in the project |
app | CNAME | same Vercel CNAME target | 60 | Shared login / tenant picker |
* (wildcard) | managed by Vercel | No record to add by hand. Point the registrar at ns1.vercel-dns.com and ns2.vercel-dns.com, then add the apex and the wildcard in the project | — | Every tenant.yourerp.com |
@ | MX | 1 smtp.google.com — the current single-host Google Workspace record; Google confirms the older ASPMX record set is still supported if you already have it | 3600 | Inbound mail |
@ | TXT | v=spf1 include:_spf.google.com ~all | 3600 | SPF for mail sent from the root domain |
send | MX | from Resend dashboard — this receives bounce and complaint reports back from mailbox providers | 3600 | Sending subdomain |
send | TXT | v=spf1 include:amazonses.com ~all (value from Resend dashboard) | 3600 | SPF for the sending subdomain |
resend._domainkey.send | TXT | p=MIGfMA0GCSq… from dashboard | 3600 | DKIM public key |
_dmarc | TXT | v=DMARC1; p=none; rua=mailto:dmarc@yourerp.com; adkim=r; aspf=r | 3600 | DMARC policy, start at none |
@ | CAA | 0 issue "letsencrypt.org" | 3600 | Only Let's Encrypt may issue. Vercel adds this at the apex for you. |
status | CNAME | from status-page vendor | 3600 | Public status page on a different host to your app |
The records the customer adds to their own zone
And on the customer's own zone, kestrel.com, for a branded subdomain:
| Name | Type | Value | TTL | Notes |
|---|---|---|---|---|
erp | CNAME | your platform's CNAME target, copied from your dashboard | 300 | The only record most customers need. |
_vercel or _acme-challenge.erp | TXT | challenge value from your platform | 300 | Only if ownership verification or DNS-01 issuance is required. Vercel asks for a TXT record when the domain is already in use on Vercel. |
@ | CAA | if the customer already has CAA records, they must add 0 issue "letsencrypt.org" | 3600 | A pre-existing restrictive CAA record is the most common cause of a certificate that never issues. |
Write the customer-facing instructions once and include a screenshot. The person adding this record is usually a marketing manager with a website-builder login, not a network engineer.
Give them a single-page document with the exact host, exact type, exact value, and a note that some control panels want erp in the name field while others want the full erp.kestrel.com. Then verify it yourself with dig +short CNAME erp.kestrel.com before telling them it is live. Assume at least one round of corrections.
TLS and certificates
What a certificate actually proves
A TLS certificate binds a public key to a list of hostnames, signed by a certificate authority. When a browser connects to kestrel.yourerp.com, the server presents the certificate; the browser checks that the hostname is listed, that the signature traces up a chain to a root certificate already in the browser's trust store, and that the current date falls inside the validity window. If all three hold, the browser trusts that it is talking to whoever controls that hostname.
Note the narrowness of that claim. A certificate proves control of a name. It says nothing about whether the operator is a legitimate business, is solvent, or is running a phishing site. The old "extended validation" certificates tried to signal more; browsers stopped displaying the difference in 2019.
Automatic issuance: ACME and Let's Encrypt
Let's Encrypt is a free, automated CA. ACME (Automatic Certificate Management Environment) is the protocol clients use to talk to it. The flow is: your client asks for a certificate for a name, the CA replies with a challenge, the client proves control of the name, the CA issues a certificate valid for 90 days, and the client renews at around day 60. If you host on Vercel, Supabase, Fly.io, Cloudflare or Render, all of this happens without you doing anything; you should still understand it, because the failure modes are yours to debug.
There are two challenge types you will meet:
- HTTP-01. The CA asks you to serve a specific file at
http://name/.well-known/acme-challenge/<token>. Simple, works for a single hostname, requires port 80 to be reachable. Cannot issue wildcards. - DNS-01. The CA asks you to publish a TXT record at
_acme-challenge.name. Requires programmatic control of DNS, works when the server is not publicly reachable, and is the only way to get a wildcard certificate.
Wildcards, and why they force a DNS decision
A wildcard certificate covers *.yourerp.com: every single-label subdomain, in one certificate, renewed once. That is exactly what subdomain-per-tenant wants. But because wildcards need DNS-01, whoever issues the certificate must be able to write TXT records in your zone. That is why Vercel requires you to move the domain to ns1.vercel-dns.com / ns2.vercel-dns.com before it will issue a wildcard. If keeping DNS at Cloudflare matters more to you than wildcards do, the alternative is to add each tenant subdomain to the project individually through the Domains API when you create the tenant; certificates are then issued per hostname and you never need a wildcard.
A wildcard certificate's private key covers every subdomain it names, and that has a security cost. If a wildcard key leaks, every tenant hostname is impersonable. Per-hostname certificates limit that. On a managed platform the key never leaves the platform, so this is mostly theoretical, but it is the reason security-conscious enterprises sometimes ask you not to use wildcards.
On-demand issuance for customer domains
Custom customer domains cannot be pre-issued, because you do not know the name until the customer asks and you cannot pass the challenge until their DNS is right. The pattern is:
- Customer tells you the hostname. You insert a
tenant_domainrow withcert_status = 'pending'. - You call the platform API to add the domain (on Vercel,
projectsAddProjectDomainfrom@vercel/sdk). - You show the customer the exact DNS record to create, and a "check now" button.
- The button calls the platform's verify endpoint. When it reports verified, the platform requests the certificate. You update
verified_atandcert_status. - A background job re-checks pending domains every few minutes and gives up, with an email to you, after 72 hours.
// apps/admin/src/domains/add-custom-domain.ts
import { VercelCore as Vercel } from '@vercel/sdk/core.js'
import { projectsAddProjectDomain }
from '@vercel/sdk/funcs/projectsAddProjectDomain.js'
import { projectsVerifyProjectDomain }
from '@vercel/sdk/funcs/projectsVerifyProjectDomain.js'
const vercel = new Vercel({ bearerToken: process.env.VERCEL_TOKEN! })
const idOrName = process.env.VERCEL_PROJECT_ID!
const teamId = process.env.VERCEL_TEAM_ID!
// Only ever accept a hostname we have normalised ourselves. This
// value ends up in an API call and in a primary key.
const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9-]+)+$/
export async function addCustomDomain(tenantId: string, raw: string) {
const host = raw.trim().toLowerCase()
if (!HOSTNAME.test(host) || host.length > 253) {
throw new Error(`not a hostname: ${raw}`)
}
await pool.query(
`insert into tenant_domain (hostname, tenant_id, cert_status)
values ($1, $2, 'pending')
on conflict (hostname) do nothing`,
[host, tenantId],
)
await projectsAddProjectDomain(vercel, {
idOrName, teamId, requestBody: { name: host },
})
}
export async function checkCustomDomain(host: string) {
const res = await projectsVerifyProjectDomain(vercel, {
idOrName, teamId, domain: host,
})
const verified = Boolean(res.value?.verified)
await pool.query(
`update tenant_domain
set verified_at = case when $2 then now() else null end,
cert_status = case when $2 then 'issued' else 'pending' end
where hostname = $1`,
[host, verified],
)
return verified
}
These two functions are the whole customer-domain feature. addCustomDomain normalizes and validates the hostname, records your intent locally, and registers the hostname with the platform, which immediately starts trying to issue a certificate. checkCustomDomain asks the platform whether the DNS is correct yet and writes the answer back to your table so the admin UI and the tenant resolver agree.
Note the rate limits: Vercel allows 100 domain additions and 50 verifications per hour per team, so a bulk import needs a queue rather than a loop. You should see verified: false until the customer's CNAME resolves, then true within a minute or two of it appearing. Pair it with projectsGetProjectDomain when you want the full status to show on a support screen.
Let's Encrypt allows 50 certificates per registered domain every 7 days, refilling at one every 202 minutes, and only 5 certificates for the exact same set of identifiers every 7 days, refilling at one every 34 hours (Let's Encrypt rate limits, retrieved 25 July 2026). A retry loop that deletes and re-adds a domain will exhaust the duplicate limit in an afternoon, and then you cannot issue for that name again for days. Fix the DNS; do not re-request. There is also a limit of 5 authorization failures per identifier per account per hour, so hammering "check now" against DNS that is not ready actively slows you down. Their documentation is explicit that "revoking certificates does not reset rate limits".
HSTS
HSTS (HTTP Strict Transport Security) is a response header that tells a browser "for the next N seconds, never talk to this host over plain HTTP, and refuse to let the user click through a certificate warning". It closes the gap where a user types kestrel.yourerp.com, gets an HTTP request, and could be intercepted before the redirect to HTTPS.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
max-age=63072000 is two years in seconds. includeSubDomains extends the rule to every subdomain. preload signals that you want the domain baked into browsers' built-in lists so even the first visit is protected. The preload list at hstspreload.org requires max-age to be "at least 31536000 seconds (1 year)", requires both includeSubDomains and preload, requires a redirect "from HTTP to HTTPS on the same host, if you are listening on port 80", and requires you to "serve all subdomains over HTTPS".
The submission site says domains "can be removed, but it takes months for a change to reach users with a Chrome update and we cannot make guarantees about other browsers" (hstspreload.org, retrieved 25 July 2026). If you preload yourerp.com with includeSubDomains and later need a subdomain to serve plain HTTP — an old integration partner, a device that cannot do TLS — you are stuck for a browser release cycle or two. Ship the header with a short max-age first, run it for a month, raise it, and only then submit for preload. Never preload a customer's domain on their behalf without written agreement.
Identity, sessions and single sign-on
Your domain strategy decides how hard login is. The vocabulary comes first, then what each strategy costs you at the login screen.
The words
SSO (single sign-on) means a user authenticates once with their employer's identity provider and gets into your app without a separate password. An identity provider (IdP) is the system that holds the accounts — Microsoft Entra ID, Google Workspace, Okta.
OIDC (OpenID Connect) is the modern standard for this. It is a thin identity layer on top of OAuth 2.0: the user is bounced to the IdP, logs in there, and comes back to a redirect URI you registered in advance, carrying a code you exchange for an ID token. That token is a JWT (JSON Web Token) — three base64url-encoded pieces separated by dots, holding JSON claims like "who this is" and "when this expires", plus a signature. A JWT is tamper-evident but not secret: anyone holding it can read every claim inside. Never put anything in a JWT you would not show the user.
SAML is the older XML-based equivalent, still the only option some enterprise IT departments will accept. Instead of a redirect with a code, the user's browser POSTs a signed XML assertion to your ACS URL (Assertion Consumer Service URL) — the single endpoint that receives and validates it.
MFA (multi-factor authentication) asks for a second proof. TOTP (time-based one-time password) is the six-digit code from an authenticator app: your server and the app share a secret, both hash it with the current 30-second time window, and the numbers match. It is easy to add and phishable — a convincing fake login page can ask for the code and replay it within 30 seconds.
WebAuthn (the standard behind passkeys) stores a private key on the user's device or security key and signs a challenge from your site. It is phishing-resistant because the browser will only sign for the exact domain the credential was registered against; a lookalike domain gets nothing.
What this costs you per domain strategy
That last property of WebAuthn is a hosting decision in disguise. A passkey is bound to a Relying Party ID, which is a domain name. Register with an RP ID of yourerp.com and the passkey works on every *.yourerp.com subdomain. Register per-subdomain and each tenant needs its own. And a passkey registered on erp.kestrel.com is useless on kestrel.yourerp.com, because they are different domains, so a customer who moves to a branded domain has to re-enroll every user.
| Login concern | Shared app hostname | Subdomain per tenant | Customer's own domain |
|---|---|---|---|
| OIDC / OAuth redirect URIs | One, registered once. | One if you terminate login on app.yourerp.com and redirect back; otherwise one per subdomain. | One per customer domain, registered by hand. Every new customer is an IdP config change. |
| SAML ACS URL | One. | One shared ACS, then redirect to the tenant hostname. | One per customer unless you centralize on a shared login host. |
| Passkey / WebAuthn RP ID | The app hostname. | yourerp.com, so one passkey covers all tenants. | The customer's domain. Re-enrollment required if they ever change it. |
| Session cookie | Host-only. Simplest. | Host-only or .yourerp.com. Pick deliberately. | Host-only, always. There is no cross-domain sharing. |
| Supabase Auth callbacks | Unchanged. | Unchanged. | Supabase warns that "OAuth flows will advertise the custom domain as a callback URL" and that you must add it in addition to the project URL in every provider. |
So, whatever the customer's branded URL is, terminate login on one hostname you control. Send the user to app.yourerp.com to authenticate, then bounce them back with a short-lived, single-use token that the tenant hostname exchanges for its own session cookie. You then register one redirect URI per identity provider instead of one per customer, and passkeys keep working when a customer changes their domain.
Cookie attributes that matter
| Attribute | Set it to | Why |
|---|---|---|
Secure | Always | The browser refuses to send the cookie over plain HTTP. |
HttpOnly | Always, for session cookies | JavaScript cannot read it, so a cross-site scripting bug cannot steal the session. |
SameSite | Lax normally; None only if you genuinely need cross-site POSTs, and then Secure is mandatory | Blocks most cross-site request forgery without any extra code. |
Domain | Omit it unless you have decided you want cross-subdomain sessions | Omitting it makes the cookie host-only: the smallest blast radius available. |
Path | / | Path offers no security guarantee. Do not use it as one. |
__Host- name prefix | Use it whenever the cookie is host-only | The browser rejects the cookie outright if Domain is set, Path is not /, or Secure is missing. A guard your server cannot forget. |
// packages/auth/session-cookie.ts
import 'server-only'
import { cookies } from 'next/headers'
const ROOT = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'yourerp.com'
// Decide this once, deliberately, and write down why.
// false = one login per tenant hostname (isolated sessions)
// true = one login across every *.yourerp.com tenant
const SHARE_ACROSS_TENANTS = false
export async function setSessionCookie(token: string) {
const jar = await cookies()
jar.set({
name: SHARE_ACROSS_TENANTS ? 'erp_session' : '__Host-erp_session',
value: token,
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 8, // 8 hours, then re-auth
...(SHARE_ACROSS_TENANTS ? { domain: `.${ROOT}` } : {}),
})
}
One flag decides the whole session model, so it lives at the top of the file with a comment rather than buried in a call site.
With SHARE_ACROSS_TENANTS false you get a host-only cookie carrying the __Host- prefix, which means the browser itself refuses the cookie if a future edit accidentally adds a Domain or drops Secure. With it true you get one login across every tenant subdomain and you must keep the per-request membership check in withTenant.
Browsers treat http://localhost as a secure context, so secure: true and the __Host- prefix both still work in local development. To verify, log in and look at the Application tab in your browser's developer tools: you should see the cookie with no Domain value and the HttpOnly and Secure boxes ticked.
Environments
You will run four environments. Each is a complete copy: URL, database, secrets, background workers. Keeping them straight is mostly a matter of never letting two of them share anything.
| Environment | URL | Database | Data it holds | Who can reach it | Lifetime |
|---|---|---|---|---|---|
| Local | kestrel.localhost:3000 | Postgres in a container, or supabase start | Seed fixtures only — small made-up records you load on demand. Fake brands, fake buyers, fake styles. | You | Wiped whenever you like |
| Preview (per pull request) | erp-git-pr-142-you.vercel.app | Ephemeral branch database, seeded from fixtures | Seed fixtures. Never production data. | Your team; behind deployment protection | Deleted when the PR closes |
| Staging | staging.yourerp.com | Its own long-lived database | Anonymized copy of production, refreshed on a schedule | Your team plus named customer testers | Permanent |
| Production | *.yourerp.com, customer domains | The real one, with point-in-time recovery | Real customer data | Customers | Permanent, backed up |
Preview deployments
Connect the Git repository to your host and every pull request gets its own URL running that branch's code. This is the single highest-value piece of infrastructure for a small team: a merchandiser can click a link in the PR and try the new size-run editor before it is merged.
Protect them. On Vercel, Deployment Protection using Vercel Authentication is available on every plan, and its Standard Protection scope covers every deployment except production domains (docs last updated 26 June 2026). On the Hobby plan that is as far as you can go — protecting a production domain needs Pro or Enterprise. Turn it on before you connect the repository, and set it as the team default so new projects inherit it.
One caveat from the same docs: once Standard Protection is on, the generated production URL is protected too, so any server-side fetch that used VERCEL_URL must be changed to use the domain the user actually requested.
The hard part is the database. A preview deployment pointed at the production database will eventually destroy real data — a test order, a bulk edit, a migration run by accident. The two clean answers are database branching (below) or a shared, disposable "preview" database that is dropped and re-seeded nightly. Pick one before you connect the repo, not after.
Why production data must not be copied into staging
The temptation is enormous: staging with real data catches real bugs. The reasons to refuse are concrete. Your staging environment has weaker access controls, is likely reachable by contractors, has no audit trail worth the name, and its backups may not be encrypted the same way.
Under GDPR and UK GDPR, a copy of customer data in staging is processing you must be able to justify, and a breach there is as reportable as one in production. Buyer contact details, credit limits, cost prices and margin are exactly the data a competitor would pay for.
The workable compromise is a scrubbed restore: take a production backup, restore it into an isolated machine, run an anonymization script, then move the result to staging. The script runs on the isolated machine and the un-anonymized restore is destroyed immediately.
-- anonymise.sql — run on an ISOLATED restore, BEFORE it becomes
-- staging. Never point this at production. Deterministic, so
-- repeated runs give the same fake values and bug reports stay
-- reproducible.
begin;
-- A stable non-negative integer derived from a uuid. md5() is used
-- rather than hashtext(), which is an internal function whose
-- output is not guaranteed stable across major Postgres versions.
create or replace function pg_temp.fake_int(id uuid, modulo int)
returns int language sql immutable as $$
select (('x' || substr(md5(id::text), 1, 7))::bit(28)::int)
% greatest(modulo, 1)
$$;
-- 1. People. Keep shape (domains, name lengths) so UI bugs show.
update app_user set
email = 'user' || substr(md5(id::text), 1, 8) || '@example.test',
full_name = 'Test User ' || substr(md5(id::text), 1, 6),
phone = '+1555'
|| lpad(pg_temp.fake_int(id, 10000000)::text, 7, '0');
-- 2. Trading partners.
update customer set
legal_name = 'Account ' || substr(md5(id::text), 1, 6),
contact_email = 'ap' || substr(md5(id::text), 1, 8) || '@example.test',
address_line1 = (pg_temp.fake_int(id, 9000) + 100)::text
|| ' Test Street',
tax_id = null;
-- 3. Commercially sensitive numbers: jitter, do not null, so that
-- margin reports still render and still look plausible.
update product_cost set
landed_cost = round(landed_cost
* (0.85 + pg_temp.fake_int(id, 31) / 100.0), 2);
-- 4. Anything that can reach the outside world.
update integration_credential set secret_ciphertext = null;
update webhook_endpoint set url = 'https://sink.example.test';
delete from outbound_email_queue;
delete from payment_method;
-- 5. Hostnames. Staging must not claim production hostnames, or
-- its outbound links point at the live system.
delete from tenant_domain where not is_primary;
update tenant_domain d set
hostname = t.slug || '.staging.yourerp.com',
cert_status = 'pending'
from tenant t
where t.id = d.tenant_id;
-- 6. Prove it. Fails loudly if anything real survived.
do $$
declare leaked int;
begin
select count(*) into leaked from app_user
where email not like '%@example.test';
if leaked > 0 then
raise exception 'app_user not anonymised: % rows', leaked;
end if;
select count(*) into leaked from customer
where contact_email is not null
and contact_email not like '%@example.test';
if leaked > 0 then
raise exception 'customer not anonymised: % rows', leaked;
end if;
select count(*) into leaked from tenant_domain
where hostname not like '%.staging.yourerp.com';
if leaked > 0 then
raise exception 'production hostnames survived: % rows', leaked;
end if;
end $$;
commit;
Read this as a template, not a finished artifact — your schema differs, and if secret_ciphertext is not null in your schema you will need to write a dummy value instead of null. The important habits are:
- Derive every fake value deterministically from the row id, so the same record gets the same fake value on every run.
- Preserve the shape of data so layout bugs still appear.
- Destroy anything that could send an email, charge a card or call a partner API.
- Rewrite the hostname table so staging cannot mint links pointing at production.
- And end with assertions that raise an exception if real data survived, so a half-finished script fails the job instead of quietly shipping personal data to staging.
The cost jitter multiplies each landed cost by a factor between 0.85 and 1.15, which keeps margin reports believable while making every number wrong. A healthy run prints COMMIT. If it raises, the transaction rolls back and you have a table you forgot.
Every environment that is not production gets a database whose contents you would be comfortable pasting into a public chat. If that sentence makes you uncomfortable about staging, staging is not anonymized enough yet.
Environment variables and secrets
Vercel exposes three built-in environments — Production, Preview, Development — plus custom environments, and each variable is assigned to one or more of them. Preview variables can be scoped to a single Git branch, and a branch-specific value overrides the general preview value.
# Link the local folder to the Vercel project (once).
vercel link
# Pull the Development values into .env.local.
# This OVERWRITES .env.local. Commit nothing; back up first if
# you have hand-edited values in there.
vercel env pull .env.local
# Add a secret without it ever touching your shell history:
# read it silently, then pipe it in.
read -rs -p 'RESEND_API_KEY: ' NEW_KEY; echo
printf '%s' "$NEW_KEY" | vercel env add RESEND_API_KEY production
unset NEW_KEY
# Branch-scoped preview value.
vercel env add DATABASE_URL preview feature/size-runs
# See what exists, without printing values.
vercel env ls
# Remove one. This is destructive: the next deployment to that
# environment will not have the variable. Rotate first, then remove.
vercel env rm RESEND_API_KEY preview
These commands are the whole workflow. vercel env pull writes a .env.local file that your local dev server reads, so a new developer gets a working machine in two commands, but it overwrites the file, so treat it as a fetch, not a merge.
Reading the value with read -rs and piping it into vercel env add keeps the secret off your terminal and out of your history file; unset removes it from the shell's memory afterwards.
vercel env ls lists names and target environments but not values, which is the behavior you want when screen-sharing. vercel env rm takes effect on the next deployment, so removing a key that is still in use breaks that environment.
# .gitignore — the parts that matter
.env
.env.local
.env.*.local
*.pem
.vercel/
# ...but keep the template, which holds names and dummy values only
!.env.example
Commit .env.example with every variable name and an obviously fake value; never commit a file with real values. The last line begins with !, which un-ignores that one file, so a broad rule above it never swallows the template. If you ever do commit a real secret, rotate the key immediately and assume it is already compromised — Git history is forever, and public repositories are scraped within minutes.
// packages/config/env.ts — fail at boot, not at 4pm on a Friday
import { z } from 'zod'
// Zod 4 offers a top-level z.url(); the older z.string().url()
// chain still works. Check which major version you have installed
// and use one form consistently.
const url = () => z.url()
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
VERCEL_ENV: z.enum(['development', 'preview', 'production'])
.default('development'),
NEXT_PUBLIC_ROOT_DOMAIN: z.string().min(3),
DATABASE_URL: url(),
DIRECT_DATABASE_URL: url(), // migrations, unpooled
SUPABASE_SECRET_KEY: z.string().min(20), // server only
RESEND_API_KEY: z.string().startsWith('re_'),
POWERSYNC_URL: url(),
SENTRY_DSN: url().optional(),
})
const parsed = schema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid environment:',
parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
// Guard rail: a production build must never point at a local DB.
if (env.VERCEL_ENV === 'production' &&
env.DATABASE_URL.includes('localhost')) {
throw new Error('production build pointed at localhost')
}
This validates every environment variable at process start and exits with a readable list of what is missing. Without it, a missing RESEND_API_KEY shows up as an undefined-property crash in an order-confirmation email three hours after deploy. The last guard catches the specific mistake of a production deployment that inherited a developer's local database URL.
Delete one variable from .env.local, run pnpm dev, and you should see the process refuse to start with the offending variable named. Note the deliberate absence of any NEXT_PUBLIC_ prefix on the secrets: on Next.js that prefix is what ships a value to the browser bundle, so a typo there is a live key on your marketing page.
Database branching
Branching gives each pull request its own database. Supabase's preview branches integrate with GitHub: pushing to a PR branch creates an isolated environment, runs your migrations from supabase/migrations, applies seed files, and deploys edge functions. The branches are deliberately data-less — they do not copy production data, and are paused or deleted when the PR closes. Branching is billed at $0.01344 per branch per hour (Supabase pricing, retrieved 25 July 2026), which is about $9.80 for a branch left running for a full 730-hour month and pennies for a branch that lives two days.
Neon takes a different approach: branches are copy-on-write clones, meaning the branch shares the parent's existing data blocks and only stores a new copy of a block when something changes it. A branch of a production database is therefore instant and initially adds no storage. Launch includes 10 branches per project and Scale includes 25, with extra branches at $1.50 per branch-month (Neon pricing, retrieved 25 July 2026). That copy-on-write behavior is genuinely useful for debugging a production data problem, but it also means the branch contains production data, so treat any such branch as production for access-control purposes and delete it when you are done.
# .github/workflows/preview-db.yml
name: Preview database
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with: { version: latest }
# Apply every migration to the branch database for this PR.
- name: Push migrations
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_URL: ${{ secrets.PREVIEW_DB_URL }}
run: supabase db push --db-url "$SUPABASE_DB_URL"
# DESTRUCTIVE: wipes the target database, then replays every
# migration from empty. Safe ONLY because PREVIEW_DB_URL points
# at a throwaway per-PR database. Never store a production URL
# in that secret.
- name: Reset and replay from scratch
env:
SUPABASE_DB_URL: ${{ secrets.PREVIEW_DB_URL }}
run: supabase db reset --db-url "$SUPABASE_DB_URL"
This workflow runs on every push to a pull request. The first step applies pending migrations to the PR's own database. The second wipes it and replays every migration from empty, which catches the common failure where migration 41 only works because of leftover state from a hand-edited table.
Read the warning in the comment carefully: supabase db reset drops everything in the target database, so the PREVIEW_DB_URL secret must never hold a production or staging connection string. Restrict that secret to the workflow that needs it. On a healthy PR both steps go green; when someone writes a migration that is not replayable, step two fails with a SQL error naming it, and the PR cannot merge.
Backups, restores, RPO and RTO
Two numbers describe every backup plan, and you should be able to say both out loud for your system.
RPO (recovery point objective) is how much data you are willing to lose, measured in time. A daily backup taken at 02:00 gives an RPO of up to 24 hours: if the database dies at 01:59, yesterday's orders are gone.
RTO (recovery time objective) is how long you are willing to be down while you restore. The business decides both numbers; engineering only implements them. For a wholesale ERP during market week, an RPO of 24 hours means losing a day of written orders, which no brand will accept, so you buy a smaller number.
Point-in-time recovery
PITR (point-in-time recovery) is what buys it. Postgres writes every change to a write-ahead log (WAL) before applying it. If you keep a base backup — one full copy of the database at a moment in time — plus every WAL segment since, you can replay to any second in the retention window. That turns an RPO of 24 hours into an RPO of roughly one minute.
On Supabase it is a paid add-on at $100 per month per 7 days of retention (Supabase pricing, retrieved 25 July 2026). Neon's equivalent is instant restore at $0.20 per GB-month, charged on root branches.
| What went wrong | How you recover | Realistic RPO | Realistic RTO |
|---|---|---|---|
| A user deleted one order | Your own append-only ledger and soft deletes — the order row is flagged deleted rather than removed. No restore needed. | 0 | Minutes |
| A migration corrupted a table | PITR to one minute before the migration ran, into a new project; copy the good rows back. | ~1 min | 1–4 hours |
| Someone dropped a schema | PITR to just before the drop. | ~1 min | 1–4 hours |
| The database is gone entirely | Restore the most recent base backup plus WAL. | ~1 min with PITR; up to 24 h on daily backups alone | 2–8 hours |
| Your cloud region is down | Wait, or restore into another region if you already hold an off-platform copy. | Depends on your last export | Hours to a day |
| Credentials compromised, data exfiltrated or ransomed | Rotate every key, restore to before the compromise, notify. A backup the attacker can also delete will not survive the attack. | Varies | Days |
| Vendor account closed or billing lapsed | Only your own off-platform export saves you. | Since your last export | Days |
Restore drills and an off-platform copy
The last two rows are the reason to keep one copy your hosting provider cannot touch. A weekly pg_dump to object storage in a different account, with its own credentials, costs almost nothing and covers the failure modes that platform backups cannot.
# 1. Logical backup. Run it against a read replica or off-peak;
# a big dump competes with real traffic for I/O.
pg_dump "$DIRECT_DATABASE_URL" \
--format=custom --no-owner --no-privileges \
--file="/secure/backups/erp-$(date -u +%Y%m%dT%H%M%SZ).dump"
# 2. Restore into a SCRATCH database. Never into production, and
# never into staging without running anonymise.sql after it.
createdb erp_restore_test
pg_restore --dbname=erp_restore_test --no-owner --jobs=4 \
/secure/backups/erp-20260725T090000Z.dump
# 3. Prove the restored database is usable. A clean exit code
# from pg_restore only tells you the command finished.
psql erp_restore_test -c "select count(*) from sales_order;"
psql erp_restore_test -c \
"select max(created_at) from sales_order;"
# 4. Destroy the scratch copy. Until you do, it holds real
# customer data on whatever machine you restored it to.
dropdb erp_restore_test
Step 3 is the step people skip, and it is the one that decides whether the other three were worth doing. A backup job that reports success every night proves that a file was written. Whether that file can be turned back into a working ERP is a separate question, and step 3 is the one that answers it.
Run this drill once a quarter, write down the wall-clock time it took, and put that number in your runbook as your measured RTO. Step 4 matters just as much: the scratch database contains un-anonymized production data, so it needs the same protection as production until the moment you drop it.
The first time you exercise a restore path should never be the day you need it. Two failures are extremely common and both are invisible until you try: the backup silently excludes something (a schema, large objects, a role) so the restore succeeds but the app will not start; and nobody has the credentials to create a new database at 3am because the only person with billing access is on a plane. Test the restore, and test it with the person who would actually be on call.
Hosting options compared
All prices below are list prices retrieved on 25 July 2026 from the vendors' own pricing pages: Vercel, Fly.io, Railway, Cloudflare Workers, Supabase and Neon. Cloud pricing changes several times a year and regional rates differ; treat these as a snapshot for comparison, and re-check before you commit.
| Platform | Price (25 July 2026) | Good at | Honest downsides |
|---|---|---|---|
| Vercel | Hobby free. Pro: $20 per developer seat per month, with $20 of usage credit included; 1 TB Fast Data Transfer (bytes served to visitors) and 10,000,000 Edge Requests (requests hitting the CDN) included per month; viewer seats unlimited and free; builds $0.0035 per CPU-minute. | Next.js with zero configuration. Preview deployments per PR. Automatic TLS including per-customer domains via API. Global CDN. | Usage-based billing is hard to predict until you have a month of data. No IPv6 for custom domains. Long-running jobs do not belong here — you need a queue worker on a separate service. Some add-ons are steep: SAML single sign-on $300/mo, static IPs $100/mo per project, advanced deployment protection $150/mo. |
| Fly.io | shared-cpu-1x: 256 MB $2.02/mo, 512 MB $3.32, 1 GB $5.92, 2 GB $11.11. Volumes $0.15/GB-mo of provisioned capacity. Outbound data $0.02/GB in North America and Europe. Dedicated IPv4 $2/mo. Single-hostname certificates $0.10/mo (first 10 free per organization), wildcards $1/mo. Paid support from $29/mo. | Long-running processes, background workers, WebSockets. Runs a plain container, so nothing is framework-specific. Cheap multi-region. | You operate it. Machine sizing, health checks, rolling deploys and Postgres upgrades are your problem. Community support unless you pay. |
| Railway | Hobby $5/mo with usage included; Pro $20/mo per seat. vCPU about $0.0278/hr; memory about $0.0139/GB-hr; network egress $0.05/GB; object storage $0.015/GB-mo. | The friendliest path from a Dockerfile to a running service. Good for the worker process and scheduled jobs. | Per-second resource billing surprises people who leave a big instance idle. Fewer regions than the big three. |
| Cloudflare | Workers free tier 100,000 requests/day with 10 ms CPU per invocation; Paid from $5/mo including 10 million requests and 30 million CPU-milliseconds, then $0.30 per additional million requests and $0.02 per additional million CPU-ms. Their docs state "there are no additional charges for data transfer (egress) or throughput (bandwidth)". R2 object storage with free egress. | Unbeatable on bandwidth cost and DNS. Cloudflare for SaaS gives 100 custom hostnames free on every plan, then $0.10 each. | Workers is not a general Node.js server; some npm packages will not run. Direct TCP connections to Postgres need Hyperdrive (Cloudflare's connection-pooling service) or another proxy. A meaningful rewrite if you start on Vercel. |
| Plain VPS (Hetzner, DigitalOcean) | Hetzner's shared-vCPU line runs from roughly 2 vCPU / 2 GB up to 8 vCPU / 16 GB. Prices are quoted per region and in local currency on their calculator; check it rather than trusting a figure printed in a book. | Cheapest raw compute by a wide margin. Total control. No vendor pricing surprises. | You now run an operating system: patching, firewalls, TLS renewal, log rotation, backups, monitoring, and a deploy mechanism. Realistically several hours a month forever, plus one bad weekend a year. Do not start here. |
Database options
Two units of measure appear below. MAU means monthly active users — distinct users who authenticated at least once in the month. A CU, or compute unit, is Neon's bundle of one vCPU with about 4 GB of RAM, so a CU-hour is one of those running for an hour. IOPS is input/output operations per second: how many separate reads and writes the disk will accept.
| Option | Price (25 July 2026) | Strengths | Watch out for |
|---|---|---|---|
| Supabase | Free $0: 500 MB database, 5 GB egress, 1 GB file storage, 50,000 monthly active users; projects pause after a week idle and you may have two active. Pro $25/mo: 8 GB disk then $0.125/GB, 250 GB egress then $0.09/GB, 100 GB file storage then $0.0213/GB, 100,000 MAU then $0.00325 each, daily backups kept 7 days, 7-day log retention, $10 compute credit. Compute add-ons: Micro $10, Small $15, Medium $60, Large $110, XL $210/mo. PITR $100/mo per 7 days of retention. Custom domain $10/domain/mo. Team $599/mo with 14-day backups and 28-day logs. | Postgres plus auth, storage, realtime and RLS in one place. The default choice for this book's stack. | PITR is a real line item. A read replica — a second copy of the database that serves read-only queries — is billed as a full extra database: compute, disk sized at 1.25× the primary, provisioned IOPS and throughput, and IPv4. Supabase states that "Compute Credits do not apply to Read Replica Compute" and that "Read Replicas are not covered by the Spend Cap". Free-tier projects pause. |
| Neon | Free: 0.5 GB storage per project, 100 CU-hours, 5 GB egress. Launch and Scale are usage-metered with no monthly minimum: compute $0.106/CU-hour (Launch) or $0.222 (Scale), storage $0.35/GB-mo, 500 GB egress per project included then $0.10/GB, 10 included branches on Launch and 25 on Scale with extras at $1.50/branch-mo, instant restore $0.20/GB-mo. | Copy-on-write branching is the best in the category. Scale-to-zero, shutting the compute down entirely when no query has arrived for a while, means idle preview databases cost nothing. | Postgres only — you supply auth, storage and realtime yourself. Waking a scaled-to-zero database takes a moment, and users notice it if you enable that on production. |
| Amazon RDS | Per-instance-hour plus storage plus backup storage. Rates vary by instance class and region; use the AWS pricing calculator rather than a number from a book or a blog post. | Everything AWS: VPC networking, IAM, long automated backup retention, Multi-AZ failover, huge instance ceiling. | The most operational surface of the three. You configure subnets, parameter groups and security groups before you write a line of SQL. Justified when a customer's security review demands it. |
| Self-managed Postgres | Just the VPS. | Cheapest, and you learn a great deal. | You own backups, PITR via WAL archiving, major-version upgrades, replication and 3am recovery. For an ERP holding other companies' order books, the risk-adjusted cost is not competitive. |
If you do run your own server
Some customers will require it, usually for reasons of data residency. Two pieces of the operating system do the work a managed platform hides from you: a reverse proxy that terminates TLS, and a service manager that keeps your app running.
systemd is the service manager on nearly every modern Linux distribution. You describe your program in a unit file; systemd starts it at boot, restarts it if it crashes, captures its output into the journal, and applies sandboxing. Caddy is a reverse proxy that obtains and renews Let's Encrypt certificates automatically, so you get the same TLS behavior as a managed platform with a five-line config.
# /etc/caddy/Caddyfile
# Caddy requests and renews the certificate on its own. Port 80
# must stay open for the HTTP-01 challenge and the redirect.
erp.example.com {
encode zstd gzip
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
X-Frame-Options DENY
}
reverse_proxy 127.0.0.1:3000
}
Caddy listens on 443, presents the certificate, and forwards each request to your Node process on localhost port 3000, which is not exposed to the internet at all. encode zstd gzip compresses responses using whichever of those two methods the browser says it understands. The header block adds the same four security headers used on Vercel later in this chapter. Note the absence of preload on the HSTS header — add it only after the deliberate rollout described earlier. After sudo systemctl reload caddy, check the result with curl -sI https://erp.example.com | grep -i strict and confirm that curl -sI http://erp.example.com returns a 308 redirect to HTTPS.
# /etc/systemd/system/erp-web.service
[Unit]
Description=ERP web application
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=erp
Group=erp
WorkingDirectory=/srv/erp
# Secrets live here, root-owned, group-readable by erp only:
# sudo chown root:erp /etc/erp/web.env
# sudo chmod 640 /etc/erp/web.env
EnvironmentFile=/etc/erp/web.env
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=5
# Sandboxing. Each line removes something the app does not need.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/erp/.next/cache
[Install]
WantedBy=multi-user.target
This runs the app as an unprivileged erp user rather than root, restarts it five seconds after any crash, and reads secrets from a file that only root can write and only the erp group can read, which is the step people skip, leaving database passwords world-readable. ProtectSystem=strict makes the whole filesystem read-only to the process except the paths you list, so a compromised app cannot rewrite your binaries.
Install it with sudo systemctl daemon-reload && sudo systemctl enable --now erp-web, then confirm with systemctl status erp-web and read the logs with journalctl -u erp-web -f. Kill the process by hand and watch systemd bring it back within five seconds.
Region selection, and why co-location is not optional
Put the app and the database in the same region. Being on the same continent is not close enough.
The arithmetic is unforgiving. A single page in an ERP is rarely one query. An order-detail screen might run twenty: header, lines, allocations, customer, credit status, shipping addresses, audit trail. At 1 ms round trip inside a region, that is 20 ms of network time and nobody notices. At 75 ms across the Atlantic, it is 1.5 seconds of pure waiting on a page that does no work. Add a connection handshake and TLS negotiation on a cold connection and it is worse.
| Path | Typical round trip | What 20 sequential queries costs |
|---|---|---|
| Same region, same availability zone | under 1 ms | under 20 ms |
| Same region, across zones | 1–2 ms | 20–40 ms |
| US East to US West | 60–80 ms | 1.2–1.6 s |
| US East to Frankfurt | 75–95 ms | 1.5–1.9 s |
| London to Sydney | 230–280 ms | 4.6–5.6 s |
These are order-of-magnitude figures bounded by the speed of light in fiber; measure your own with ping and with a timed loop of trivial queries from your app server. The conclusion does not change: choose the region nearest the majority of your users, put the database there, and pin your server functions to it. On Vercel that means setting the function region to match your Supabase region — both run on the same underlying AWS regions, so iad1 pairs with us-east-1.
Pinning the region and the security headers in vercel.json
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"regions": ["iad1"],
"functions": {
"app/**/*": { "maxDuration": 30 }
},
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin" },
{ "key": "X-Frame-Options", "value": "DENY" }
]
}
]
}
The regions array pins functions to Washington, D.C., which is where a us-east-1 Supabase project lives. Vercel's documented default for new projects is already iad1, but leaving it implicit means a later dashboard change can silently move your compute away from your data.
The functions keys are globs — patterns where * stands for any file or folder name — matched against source paths, so app/**/* matches route files at every depth while app/* would match only one level; in a Next.js App Router project you can also set export const maxDuration = 30 in an individual route file, which is more precise.
The headers block sets HSTS — deliberately without preload — plus three cheap security headers. Do not add X-XSS-Protection: it is obsolete and the filter it enabled introduced bugs of its own. The header that actually stops injected scripts is a Content-Security-Policy, a list of the sources a page is allowed to load code from; it needs per-application tuning and belongs with the application code. After deploying, check with curl -sI https://app.yourerp.com | grep -i strict that the header is present.
Reduce the number of round trips before you optimize the network. Co-location fixes the constant factor; it does nothing about twenty sequential queries. If a page issues one query per order line, no region choice will save you. One query that returns a shaped result is worth more than any hosting decision in this chapter.
Email and deliverability
An ERP sends order confirmations, pro forma invoices, shipping notices, statements and password resets. If those land in spam, the customer concludes the system is broken, and behaves accordingly.
Use a transactional email provider
Running your own SMTP server means acquiring IP reputation from scratch, handling bounces and complaint feedback loops, and getting delisted from blocklists. A transactional provider does that for you.
| Provider | Price (25 July 2026) | Notes |
|---|---|---|
| Resend | Free: 3,000 emails/mo, 1 domain, and a hard cap of 100 emails per day. Pro from $20/mo for 50,000 emails or $35/mo for 100,000, 10 domains, overage $0.90 per 1,000. Scale from $90/mo for 100,000, with overage falling toward $0.46 per 1,000 at high volume. Free and Pro carry 30-day message retention. Dedicated IP $30/mo as a Scale add-on, and only for senders above 3,000 emails a day. | Cleanest developer experience; React Email templates. The default for this stack. Watch the free plan's daily cap: a batch of 200 statements will not send. |
| Postmark | Free 100/mo. Basic $15/mo for 10,000 emails, overage $1.80 per 1,000, 5 domains. Pro $16.50/mo, overage $1.30 per 1,000, 10 domains. Platform $18/mo, overage $1.20 per 1,000, unlimited domains. Retention 45 days by default, extendable to 365 days as an add-on from $5/mo on Pro and above. Dedicated IPs from $50/mo per IP, requiring 300,000+ emails a month. DMARC monitoring from $14/mo per domain. | Long-standing reputation for transactional deliverability; separates transactional and broadcast streams, which protects your reputation. |
| Amazon SES | Usage-priced per thousand emails; cheapest at volume. Check the current AWS rate for your region. | You build templates, suppression handling and dashboards yourself. Sensible once you send hundreds of thousands. |
Sending subdomain versus root domain
Send your ERP's mail from a subdomain such as send.yourerp.com or notifications.yourerp.com, not from yourerp.com itself. Reputation is tracked partly per domain. If a marketing blast from the root domain collects complaints, the root domain's reputation suffers, and you do not want your customers' invoices caught in that. Separating sending subdomains also lets you set different DMARC policies and retire a burnt subdomain without touching your corporate email.
SPF, DKIM and DMARC in plain language
SPF (Sender Policy Framework) is a TXT record listing which servers are allowed to send mail claiming to be from your domain. A receiving server checks the connecting IP address against that list.
Name: send.yourerp.com
Type: TXT
Value: v=spf1 include:amazonses.com ~all
v=spf1 identifies the record version. include:amazonses.com pulls in your provider's list of sending IPs — use the exact include value your provider's dashboard gives you, since providers change infrastructure. ~all is "softfail": anything not listed is suspicious but not automatically rejected. -all is a hard fail, ?all is neutral. Start at ~all; move to -all once you are certain every sending path is listed.
One hard rule from RFC 7208 §4.6.4: an SPF record may trigger at most 10 DNS-querying mechanisms (include, a, mx, ptr, exists, redirect) during evaluation, and exceeding that makes the whole record a permerror, which receivers usually treat as no SPF at all. Stacking four vendors' include statements is how people quietly break their own email.
DKIM (DomainKeys Identified Mail) signs each outgoing message with a private key. The matching public key lives in DNS. A receiver fetches the key and verifies the signature, proving the message was not altered in transit and really came from someone holding your key.
Name: resend._domainkey.send.yourerp.com
Type: TXT
Value: p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ… (from provider)
The label before ._domainkey is the selector — here resend. Selectors let you run several keys at once, which is how you rotate keys without an outage: publish the new selector, switch the provider to sign with it, wait a week, remove the old one. Providers typically offer 1024-bit or 2048-bit keys; take the 2048-bit option where your DNS panel can hold the longer record. Copy the value exactly; a single wrapped line or stray space breaks verification, and some DNS control panels helpfully insert quotation marks that you must remove.
DMARC and alignment
DMARC ties the two together and tells receivers what to do when they fail. It also asks receivers to send you reports.
Name: _dmarc.yourerp.com
Type: TXT
Value: v=DMARC1; p=none; rua=mailto:dmarc@yourerp.com;
adkim=r; aspf=r; pct=100
p= is the policy: none (monitor only), quarantine (send to spam), reject (refuse outright). rua= is where aggregate XML reports go — point it at a mailbox or a DMARC-report service, because raw reports are unreadable by hand. adkim and aspf set alignment strictness: r (relaxed) lets a subdomain satisfy alignment for the parent, s (strict) demands an exact match. Alignment is the part people get wrong. SPF or DKIM passing is only half of it; the domain that passed must also match the domain in the From: header the human sees. That match is what stops an attacker passing SPF for their own domain while displaying yours.
The rollout is always: publish p=none, read reports for two to four weeks until every legitimate sender shows as aligned, move to p=quarantine (optionally with pct=25 first), then p=reject.
The current bulk-sender requirements
Google and Yahoo both began enforcing a shared set of rules in February 2024, and both still publish them as current requirements as of 25 July 2026. Google's threshold is explicit: "Starting February 1, 2024, email senders who send more than 5,000 messages per day to Gmail accounts must meet the requirements in this section."
| Requirement | All senders | Bulk senders (5,000+ msgs/day to Gmail) |
|---|---|---|
| Authentication | SPF or DKIM | SPF and DKIM, plus a DMARC record (Yahoo accepts "at least p=none") |
From: alignment | — | Google: "the domain in the sender's From: header must be aligned with either the SPF domain or the DKIM domain" |
| Forward and reverse DNS (PTR) — a reverse lookup of your sending IP must return a hostname that resolves back to that same IP | Required | Required |
| TLS on the SMTP connection | Required | Required |
| Spam complaint rate | Yahoo asks all senders to "keep your spam rate below 0.3%" | Google: "keep spam rates reported in Postmaster Tools below 0.30%", and its monitoring guidance advises staying under 0.10% |
| Message format | RFC 5322 compliant | RFC 5322 compliant |
| One-click unsubscribe | — | Required for marketing and subscribed mail: List-Unsubscribe plus List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058), plus a visible link. Yahoo requires senders to "Honor unsubscribes within 2 days". |
Order confirmations and invoices are transactional mail, so the unsubscribe requirement does not strictly apply to them, but the authentication, alignment and complaint-rate rules apply to everything. Use your provider's separate message streams so a "new season lookbook" broadcast cannot poison the reputation that carries your invoices.
# Verify what the world actually sees.
dig +short TXT send.yourerp.com
dig +short TXT resend._domainkey.send.yourerp.com
dig +short TXT _dmarc.yourerp.com
# Rough count of top-level SPF include mechanisms. Nested includes
# also count towards the limit of 10, so treat 3 or more here as a
# prompt to check properly with an SPF validator.
dig +short TXT send.yourerp.com \
| grep -o 'include:[^ "]*' | wc -l
# End-to-end check: send ONE message to the disposable address
# mail-tester.com gives you, then read the score on their page.
# $RESEND_API_KEY must come from your environment, never inline.
curl -s -X POST https://api.resend.com/emails \
-H "Authorization: Bearer $RESEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{"from":"orders@send.yourerp.com",
"to":"web-xxxxx@srv1.mail-tester.com",
"subject":"Deliverability probe",
"text":"Order KES-2026-00412 confirmation test."}'
The dig commands prove your records are published and readable, which is different from "I typed them into the panel". The grep | wc -l counts your top-level include statements as a rough guard against the 10-lookup limit.
The final request sends one real message through your provider to a disposable mail-tester address; the resulting page tells you whether SPF, DKIM and DMARC passed, whether your IP is on a blocklist, and which spam-filter rules your HTML tripped. Two cautions: this consumes one send from your monthly allowance and one of the 100 daily sends on Resend's free plan, and the API key stays in an environment variable so it never appears in your shell history or a screen recording.
Why invoices land in spam
In rough order of frequency:
- Authentication is incomplete (DKIM published but the provider not signing, or SPF broken by the 10-lookup limit).
- The
From:address is on a domain with no DMARC record while the reply-to is somewhere else. - The message is a single PDF attachment with almost no text body, which is a classic spam signature.
- A brand-new sending subdomain has no reputation and you started by sending 800 statements in one batch.
- The HTML is one big image.
- The sending IP is shared with a bad neighbor on a free tier.
- Or the recipient's own gateway quarantines all mail with attachments from outside the organization.
Practical fixes:
- Warm a new sending domain over two weeks rather than starting at full volume.
- Put the invoice details in the message body as text and make the PDF a secondary confirmation, or better, link to a signed URL and let them download it.
- Set a real
Reply-Tothat a human monitors. - Keep the subject line free of currency symbols and capitals.
- And for large customers, ask their IT to allow-list your sending domain — a one-line request that solves the problem permanently.
Finally, record delivery as an ongoing state in your database. Every outbound message gets a row with the provider's message id, its status, and the webhook-reported outcome: delivered, bounced, complained. When a buyer says "I never got the confirmation", you should be able to answer in ten seconds with a timestamp and a delivery status instead of an apology.
File and document storage
Your ERP will accumulate PDFs (invoices, packing lists, pro formas), linesheets, product images, spec sheets and customer-supplied artwork. None of these belong in Postgres as bytes.
Object storage is a service that stores files by key in a flat namespace called a bucket. Supabase Storage, Cloudflare R2, Amazon S3 and Vercel Blob are all object stores. They are cheap, effectively unlimited, and designed for exactly this. Supabase includes 100 GB of file storage on Pro and charges $0.0213/GB beyond it; R2's headline feature is free egress, which matters if customers download a lot of large linesheets (both retrieved 25 July 2026).
Design the key namespace before you upload anything, because renaming a million objects later is miserable:
tenants/{tenant_id}/invoices/{invoice_id}/{version}.pdf
tenants/{tenant_id}/linesheets/{season}/{linesheet_id}.pdf
tenants/{tenant_id}/products/{style_id}/{image_id}/original.jpg
tenants/{tenant_id}/imports/{import_id}/source.csv
public/brand/{tenant_id}/logo.png
Tenant id first means a tenant's entire footprint is one prefix — easy to size, easy to export when they leave, easy to delete when they ask you to. Including a version or a content hash in the key means objects never change once written, which lets you cache them forever. The one public/ prefix is for genuinely public assets like a logo used in an email; everything else lives in a private bucket. The prefix carries a raw tenant id, so use the opaque UUID rather than the customer's name: object keys leak into logs, support tickets and browser history.
Signed URLs
A private object must not be fetchable by anyone who guesses the URL. A signed URL is a normal URL with a cryptographic signature and an expiry baked into the query string. Anyone holding it can fetch the object until it expires; nobody can forge one.
// packages/files/signed.ts
import 'server-only'
import { createClient } from '@supabase/supabase-js'
// The secret key bypasses row-level security completely. It must
// never reach a browser bundle, a client component or a log line.
// Supabase's newer sb_secret_… keys replace the legacy
// service_role JWT, which is deprecated at the end of 2026.
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
)
/** Authorise first, sign second. Never the other way round. */
export async function invoicePdfUrl(
tenantId: string,
invoiceId: string,
userId: string,
) {
const allowed = await canReadInvoice(userId, tenantId, invoiceId)
if (!allowed) throw new ForbiddenError()
const key = `tenants/${tenantId}/invoices/${invoiceId}/v1.pdf`
const { data, error } = await admin
.storage
.from('documents')
.createSignedUrl(key, 300, { // 5 minutes
download: `invoice-${invoiceId}.pdf`, // sets the filename
})
if (error) throw error
await auditLog({
tenantId, userId, action: 'document.signed_url_issued',
subject: key, ttlSeconds: 300,
})
return data.signedUrl
}
The order of operations is the whole point: check that this user may see this invoice, and only then mint a URL. Five minutes is long enough for a browser to follow a redirect and short enough that a URL pasted into a group chat stops working before it does damage.
The download option makes the browser save the file with a sensible name instead of a UUID. The audit-log line means that if a document leaks you can say who requested it and when.
Two configuration details matter as much as the logic: persistSession: false stops the server client writing auth state to shared storage, and the key comes from SUPABASE_SECRET_KEY with no NEXT_PUBLIC_ prefix, because that prefix would inline the value into the browser bundle and hand every visitor a key that bypasses RLS. Supabase's own documentation says to use secret keys "only in backend components of your app: servers, already secured APIs (admin panels), Edge Functions, microservices", and to create a replacement and delete the old key the moment one is exposed.
CDN and image transformation
Public objects served through a CDN should carry a long cache lifetime, which is safe only because your keys never point at changed content:
Cache-Control: public, max-age=31536000, immutable
That is a year of caching, and immutable tells the browser not even to revalidate. When the image changes, the key changes, so there is nothing to invalidate. Private objects behind signed URLs should instead carry Cache-Control: private, max-age=0, no-store so a shared proxy never keeps a copy. Getting these two the wrong way round is how a competitor's linesheet ends up cached on an office proxy.
For product images, do not ship a 6 MB camera JPEG to an iPad on trade show wifi. Use an image transformation service — Vercel's Image Optimization, Supabase's image transformations, or Cloudflare Images — to resize and re-encode on request. Vercel bills transformations per thousand with cache reads and cache writes billed separately, so cache-friendly URLs (a fixed set of widths, not arbitrary ones) matter to the bill as well as to performance.
Observability and status
Structured logging and error handling inside the application belong with the application code. This is the outside view: what tells you the system is down when you are asleep.
The four layers
Uptime monitoring is an external service that fetches a URL on a schedule from several locations and alerts when it fails. Point it at an endpoint that exercises the system; a homepage often stays up when the database is dead. Build a /api/health that checks the things that actually break.
// app/api/health/route.ts
import { NextResponse } from 'next/server'
import { pool } from '@/packages/db/pool'
export const dynamic = 'force-dynamic' // never cached
export async function GET() {
const checks: Record<string, string> = {}
let ok = true
// 1. Can we reach Postgres and does it answer quickly?
try {
const t0 = Date.now()
await pool.query('select 1')
const ms = Date.now() - t0
checks.db = `ok ${ms}ms`
if (ms > 500) { checks.db = `slow ${ms}ms`; ok = false }
} catch {
checks.db = 'fail'; ok = false
}
// 2. Is the job queue draining, or is work piling up?
// Column names differ between pg-boss major versions. Check
// your installed schema before copying this query verbatim.
try {
const { rows } = await pool.query(
`select count(*)::int as n
from pgboss.job
where state = 'created'
and created_on < now() - interval '5 minutes'`,
)
checks.queue = `backlog ${rows[0].n}`
if (rows[0].n > 500) ok = false
} catch { checks.queue = 'fail'; ok = false }
// 3. Deployment identity, so alerts name the release.
checks.release = process.env.VERCEL_GIT_COMMIT_SHA ?? 'local'
return NextResponse.json(
{ ok, checks, at: new Date().toISOString() },
{ status: ok ? 200 : 503 },
)
}
This returns HTTP 200 with a JSON body when healthy and 503 when not, which is what uptime monitors key off. It checks the two things that fail independently of the web tier: the database being reachable and responsive, and the job queue draining rather than accumulating five-minute-old jobs. It reports the deployed commit so an alert tells you which release broke.
Hit it with curl -s https://app.yourerp.com/api/health | jq and you should see "ok": true and a database time in single-digit milliseconds; if the database time is 80 ms, your function region and database region do not match. Keep the response free of anything sensitive — this endpoint is public by necessity, so report status words, never connection strings or row contents.
Error tracking captures exceptions with stack traces, request context and release version, and groups repeats so one bad deploy is one alert rather than four thousand emails. Sentry's Developer tier is free with 5,000 errors a month and a 30-day lookback; Team is $26/mo and Business $80/mo when billed annually, both including 50,000 errors and up to a 90-day lookback (Sentry pricing, retrieved 25 July 2026). Tag every event with tenant id and user id, the first question about any error is "who did this happen to", and scrub personal data before it leaves your servers.
Log retention is how far back you can look. Supabase Pro keeps 7 days of logs and Team keeps 28. Seven days is enough for "why did that fail this morning" and not enough for "reconstruct what happened during market week six weeks ago". Your append-only ledger is what carries the long-term record; logs are a diagnostic tool and should not be your evidence of what happened to an order.
A status page is a public page, hosted somewhere your infrastructure is not, that says whether the system is up and what you are doing about it. Host it at status.yourerp.com pointing at a third-party service — the entire value is that it stays up when you are down, so it must not share your hosting, your database, or the fate of your DNS zone.
What to alert on versus what to merely record
| Signal | Wake you at 3am | Notify in working hours | Record only |
|---|---|---|---|
| Health endpoint failing from 2+ locations for 3+ minutes | Yes | ||
| Database unreachable or connection pool exhausted | Yes | ||
| Error rate above 2% of requests for 5 minutes | Yes | ||
| Job queue backlog above 500 jobs older than 5 minutes | Yes | ||
| Certificate expiring in under 14 days | Yes | ||
| Domain registration expiring in under 45 days | Yes | ||
| Email bounce rate above 5% in an hour | Yes | ||
| DMARC aggregate report shows an unaligned sender | Yes | ||
| Disk usage above 80% of provisioned | Yes | ||
| Monthly spend above 120% of forecast | Yes | ||
| Backup or restore drill failed | Yes | ||
| A single user's failed login attempts | Yes | ||
| Individual slow query over 1 second | Yes | ||
| Deploy started / finished | Yes | ||
| Signed URL issued | Yes |
Prune the first column aggressively. Every alert that fires and turns out to be nothing trains you to ignore the next one. If a pager rule wakes you twice and both times the answer was "it recovered by itself", either raise the threshold, lengthen the duration before it fires, or move it down to the working-hours column. An alert nobody acts on is worse than no alert, because it consumes the attention you needed for the one that mattered.
Cost modeling
Here is a realistic monthly bill for the recommended stack. Assumptions: one Next.js app on Vercel Pro with one deploying seat; Supabase Pro in the same region; PowerSync for iPad sync; Resend for transactional email; Sentry and an uptime monitor; documents in Supabase Storage. Per-tenant load is assumed at roughly 15 users, 40,000 page views and 3,000 emails per month, with two trade shows a year. All unit prices are list prices retrieved on 25 July 2026 from the vendor pages linked above, plus PowerSync and Sentry.
| Line item | 1 tenant | 10 tenants | 50 tenants | Grows with |
|---|---|---|---|---|
| Vercel Pro seats ($20/seat, $20 usage credit) | $20 | $20 | $40 (2 seats) | Team size |
| Vercel usage beyond 1 TB transfer / 10M edge requests | $0 | $0 | $25 (est.) | Page views, image traffic |
| Supabase Pro base | $25 | $25 | $25 | Flat |
| Supabase compute above the included Micro credit | $0 | $5 (Small, $15 less $10 credit) | $50 (Medium, $60 less $10 credit) | Concurrent users, query load |
| Supabase disk beyond 8 GB @ $0.125/GB | $0 | $4 (40 GB) | $27 (225 GB) | Order history, ledger growth |
| Supabase egress beyond 250 GB @ $0.09/GB | $0 | $0 | $18 (450 GB) | API traffic, document downloads |
| Supabase PITR (7-day) | $0 | $100 | $100 | Step change once you have paying customers |
| PowerSync | $0 (Free: 2 GB synced, 50 peak connections) | $49 (Pro: 1,000 peak connections, 30 GB synced) | $109 (Pro + 2,000 extra connections @ $30/1,000) | iPads online at once, sync volume |
| Resend | $0 (Free, 3k/mo, and 100/day) | $20 (Pro, 50k/mo) | $80 (Pro 100k tier $35 + 50k overage @ $0.90/1k) | Emails sent |
| Sentry | $0 (Developer) | $26 (Team, annual) | $26 | Team size, event volume |
| Uptime monitoring + status page | $0 | $25 | $25 | Number of monitors |
| Domain registration (amortised) | ~$2 | ~$2 | ~$2 | Number of domains you own |
| Estimated total per month | ≈ $47 | ≈ $276 | ≈ $527 | |
| Per tenant | $47 | $27.60 | $10.54 |
What grows fastest as tenants multiply
Read the shape of this table before the digits. Infrastructure cost per tenant falls steeply because most of the bill is fixed. The items that grow fastest, in order:
- Supabase compute, which moves in jumps ($10 → $15 → $60 → $110 → $210) rather than smoothly, so plan the jump before you need it.
- PowerSync peak concurrent connections, which spike at trade shows precisely when you cannot afford a throttle, at $30 per additional 1,000 connections.
- Email volume, which tracks order count linearly, and where the move is always to check the next tier before accepting overage (on Resend, 100,000 messages cost $35 on Pro but $90 on Scale, while overage runs $0.90 per 1,000).
- And egress from document downloads, which is why large linesheet PDFs on R2 with free egress can beat storing them next to the database.
Two items deserve a warning. Supabase read replicas are billed as full additional databases, compute credits do not apply to them, and Supabase states that they are not covered by the spend cap, so treat adding one as a deliberate purchase.
And Vercel's usage-based lines have no ceiling until you configure one. Vercel's Spend Management lets you set an amount per billing cycle and choose what happens at it, but the docs are blunt that "setting a spend amount does not automatically stop usage" unless you also enable the option to pause production deployments, and that Vercel checks usage only "every few minutes", so a runaway loop can keep spending past the line for a while. Set the amount, enable pausing, and set it below the maximum you are actually willing to pay.
Go-live checklist for the domain and hosting layer
Work through this in order. Nothing here takes long; skipping items is how launches go wrong.
Domain and DNS
- Domain registered, with auto-renew on and registrar-lock enabled.
- Registrar account uses a shared team mailbox, not one person's address, with two-factor authentication on.
- Nameservers point where you think. Confirm with
dig +short NS yourerp.com. - Apex resolves (A record) and redirects to the app or marketing site as intended.
wwwresolves and redirects.- Wildcard or per-tenant subdomains resolve. Test with a tenant that does not exist yet and confirm you get a clean "unknown tenant" page, not a stack trace.
- TTLs are back to a sensible value (3600) after any recent migration.
- CAA record present and includes your issuer.
- A calendar reminder exists 45 days before domain expiry.
TLS
- Every hostname loads over HTTPS with a valid certificate — apex,
www, app, two real tenant subdomains, every customer custom domain. - HTTP redirects to HTTPS on every one of them.
- HSTS header present.
preloadonly if you have run without it for a month and understand the removal timeline. - Certificate expiry monitoring runs as an external check of its own, independent of whatever the platform dashboard claims.
Environments
- Production, staging, preview and local each have a distinct database. Verify by connecting and running
select current_database(), inet_server_addr();in each. - Staging data is anonymized, and the anonymization script ends in an assertion.
- Preview deployments are behind deployment protection, not publicly readable.
- Environment variables are validated at boot and the app refuses to start when one is missing.
- No
.envfile with real values is in Git history. Check withgit log --all --full-history -- .env. - No secret carries a
NEXT_PUBLIC_prefix. Grep the built bundle for a fragment of each key to be sure. - Secrets have a documented rotation owner and date.
Database, region and recovery
- App region and database region match. Confirm via
/api/healthshowing single-digit-millisecond query time. - PITR enabled on production, and your target RPO and RTO are written down as numbers.
- A restore has actually been performed into a scratch project, verified with a real query, timed, and then destroyed.
- One backup copy exists outside your hosting provider's account.
- Connection pooling configured, and migrations use the direct (unpooled) URL.
- RLS is enabled and forced on every tenant-scoped table. Run a query with no tenant context and confirm zero rows.
- Sending subdomain configured, separate from the root domain.
- SPF, DKIM and DMARC published and verified with
dig. - DMARC at
p=nonewith a workingruamailbox, and a diary note to review reports in three weeks. - A real order confirmation sent to Gmail, Outlook and one corporate domain, all landing in the inbox.
- Bounce and complaint webhooks wired to your database.
- Sending volume ramped, not started at full blast, and your plan's daily cap is above your largest batch.
Storage and observability
- Documents in a private bucket; signed URLs expire in minutes; authorization happens before signing.
- Uptime monitor hitting
/api/healthfrom at least two regions. - Error tracking live, tagged with tenant and release, with personal data scrubbed.
- Status page live on a separate host, with one person named as the writer of updates.
- Alert rules match the table above, and someone has tested that the phone actually rings.
- Spend limits and billing alerts set on every vendor.
One last piece of scheduling advice: do the first customer's cutover early in the working week, never on a Friday afternoon and never during the week of a trade show. DNS changes settle over hours, certificates occasionally need a nudge, and email reputation takes days to establish. Give yourself three clear working days with the customer's staff available before anything with a deadline depends on the system.
Field notes & further reading
- Vercel — Working with DNS: a compact, accurate reference for every record type plus Vercel's TTL rules (60s default, 30s minimum, 86,400s maximum) and the flat statement that IPv6 is not supported on Vercel.
- Vercel — Multi-tenant limits: custom domains per plan (50 on Hobby, unlimited on Pro with a 100,000 soft limit), the API rate limits of 100 additions and 50 verifications per hour per team, and the nameserver requirement for wildcard certificates.
- Vercel — Multi-tenant quickstart: the working code for adding, verifying and removing a customer domain with
@vercel/sdk, plus the wildcard nameserver setup step by step. - Supabase — Custom domains: the CLI flow (
domains create→reverify→activate), the exact CNAME and_acme-challengeTXT records, and the warning that OAuth flows will advertise the custom domain as a callback URL, so you must register it in every provider before activating. - Let's Encrypt — Rate limits: the numbers that decide whether your retry logic is safe — 50 certificates per registered domain every 7 days, 5 duplicates per identical identifier set, 5 authorization failures per identifier per hour, and the note that revoking does not reset anything.
- Google — Email sender guidelines: the authoritative list of what Gmail requires, split between all senders and bulk senders, including the 5,000-messages-a-day threshold, the 0.30% spam-rate limit and the one-click unsubscribe headers.
- Yahoo — Sender best practices: the matching Yahoo requirements, including honoring unsubscribes within two days and the RFC 8058 one-click headers.
- HSTS Preload List submission: the exact header you must serve, the one-year minimum
max-age, and a frank description of how long removal takes. Read it before you addpreload. - Cloudflare — CNAME flattening: the clearest explanation of the apex-CNAME workaround, including the dangling-CNAME NODATA response that looks like a propagation problem but is not.
- Next.js — proxy.js: the file convention that replaced
middleware.tsin Next.js 16, the migration codemod, and the caution that Server Functions share a route with the page that uses them.
1. Stand up a two-tenant subdomain deployment. Register a cheap domain. Deploy the Next.js app to Vercel, move the domain to Vercel's nameservers, and add both the apex and a wildcard *.yourdomain. Create the tenant and tenant_domain tables, seed two tenants, and implement the proxy.ts and withTenant code from this chapter. Prove four things with evidence you can paste into a note: (a) curl -sI https://alpha.yourdomain and https://beta.yourdomain both return 200 over valid TLS; (b) a page rendered on alpha shows only alpha's orders while logged in as an alpha user; (c) logging in as an alpha user and manually visiting beta.yourdomain returns a 403 from the membership check, not a page of beta's data; (d) sending curl -H 'x-tenant-slug: beta' https://alpha.yourdomain/... changes nothing, because the proxy stripped the header. Then add a _dmarc record at p=none and confirm SPF, DKIM and DMARC all pass by sending one message to mail-tester.com.
2. Rehearse a DNS migration and a certificate failure. Point a spare subdomain at a placeholder host with a TTL of 3600. Lower the TTL to 60, wait an hour, then repoint it at your Vercel project and time how long the change takes to appear from three different resolvers (dig @1.1.1.1, dig @8.8.8.8, dig @9.9.9.9). Write down the elapsed time. Next, simulate a customer custom domain: add a hostname to your Vercel project that has no DNS record at all, call the verify endpoint, and observe the pending state. Build the admin screen that shows a pending domain, the exact record the customer must create, and a "check now" button — then create the record and watch it flip to issued. When you are done you should have a runbook entry with real numbers for how long your DNS takes to converge, and a working self-service domain screen you can point a customer at.
3. Prove your backups. Take a pg_dump of your production database, restore it into a scratch database, and run three checks: a row count on sales_order, the most recent created_at, and one business fact you can verify against the live system. Time the whole thing from "decision to restore" to "verified query returns the right answer" and record that number as your measured RTO. Then run anonymise.sql against the scratch copy and confirm the assertion block passes. Drop the scratch database. Put a recurring quarterly reminder in the calendar to repeat it, and name the person who does it.