Skip to content

Hardening a Production Install

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

Forge's shipped defaults are tuned for a laptop evaluation, not for a machine that faces users. Turning one into the other is a short, ordered list of deliberate settings changes: the runtime environment, three credentials that arrive with example values, the origins a browser is allowed to call from, and the handful of surfaces that answer without a Forge login. Do it before real data lands — two of the items are a one-line edit on an empty install and a careful procedure on a populated one.

Everything below is a setting published in forge-deploy's own .env.example and compose file. This page is the checklist and the reasoning; the deploy tree stays the reference. Start from Installation, keep Operations and Troubleshooting open for ports and binds, and read Backup and Restore before you finish — none of the secrets you are about to set are in a snapshot.

The ordered checklist

1. Set the runtime environment to Production. ASPNETCORE_ENVIRONMENT ships as Development, and the API treats that value as a signal to behave like a developer's box: the rate limiter is a no-op, developer-only endpoints are mapped, and the interactive OpenAPI reference is served. Set ASPNETCORE_ENVIRONMENT=Production in .env and recreate the API container. Confirm the value from inside the running container rather than from the file — an edit that never reached a recreate is the most common reason a "hardened" box is still in Development. Note the trade: the Scalar reference disappears with it, so generate your API client from a Development instance and keep it. See API Access.

2. Change the database password. Setup generates one secret and one only — the JWT signing key. POSTGRES_PASSWORD keeps its example value until you change it, and it is a one-line edit while the database is empty.

3. Change the object-storage credentials — and do it now, not later. MINIO_ROOT_USER and MINIO_ROOT_PASSWORD likewise ship as example values. The storage service accepts root credentials only on its first initialisation; once buckets hold data, editing .env and recreating leaves the API authenticating with new values against a store that still expects the old ones, and every attachment stops resolving. There is a rotation protocol for that case, documented in forge-deploy's TROUBLESHOOTING guide — which carries its own warning that the sequence is not battle-tested and should be practised on a throwaway container first. This page deliberately does not restate it; follow the copy your own tree ships.

4. Verify the JWT signing key. A fresh setup.sh run generates a random JWT_KEY, and the API refuses to start at all without a key of at least 32 characters — so a clean install is already correct here. What needs checking is an install carried forward from an older tree or a hand-assembled .env, where the example key can survive. Changing the key invalidates every issued session, so pick the moment.

5. Decide your bind addresses and firewall deliberately. The compose file defaults every service's *_BIND to loopback; standalone setup then widens most of them so LAN workflows work out of the box, while --cohost leaves them on loopback. That is a convenience default, not a security posture. Choose which services should be reachable off-box, narrow the rest by setting the individual *_BIND values in .env, and enforce the same decision at the firewall — setup only ever offers to open the two TLS ports, and only on the --public path. The port and bind table lives on Operations and Troubleshooting.

6. Terminate TLS. --public generates a self-signed certificate and takes ports 80/443; --cohost hands TLS to the reverse proxy or tunnel you already run. Either way, plain HTTP should not be how anyone outside the shop reaches the app. Whatever fronts the stack must also appear in CORS_ORIGINS — see below.

7. Settle sign-in. SSO (Google, Microsoft, generic OIDC) and its domain allow-lists are configured per Configuration and Integrations; the rule to internalise before you enable it is that SSO never provisions accounts — a federated identity links to an existing local user or is refused. Alongside it, Forge carries a real second factor: TOTP authenticator apps, WebAuthn passkeys, and recovery codes, with an admin policy that enforces MFA by role — nominate the roles, and every user holding one is required to enrol. For a shop where a handful of accounts can post payments or change costing, that policy is the highest-value switch on this page.

8. Back up the secrets you just set. A Forge snapshot contains the database and object storage. It does not contain .env — which is now the only copy of your database password, storage credentials, JWT key and integration secrets — nor your TLS material or proxy config. Store those separately and off-host. Backup and Restore has the full list.

Two things you do not have to configure: both the API and the UI's nginx emit a standard security-header set on every response (nosniff, frame-deny, a referrer policy, a permissions policy and a content-security policy). If you put your own proxy in front, make sure it passes those through rather than stripping them or adding a contradictory second copy.

CORS

The API applies one default policy: any header and any method, with credentials, from a fixed list of origins. That built-in list covers local development ports and the internal UI service name, which is why a stock single-node install works without touching it and why anything else does not. Every other origin a browser will use — your domain, a proxied hostname, a second front end — is added through the CORS_ORIGINS environment variable as a comma-separated list, merged with the built-ins, read at startup. Edit .env and recreate the API.

Two consequences worth knowing before you debug this at speed. Because the policy allows credentials, a wildcard origin is not an option — you must list origins explicitly, including the www. form and every subdomain people actually type. And the symptom of a missing entry is rarely a clear error: SignalR's negotiate step checks the Origin header even on same-host paths, so the visible failure is usually "real-time updates stopped working" rather than a blocked request. API Access covers the hub paths themselves.

Rate limiting

Forge runs a single global fixed-window limiter, partitioned by authenticated user name or, for anonymous callers, by remote IP, with no queue — over the limit, callers get HTTP 429. A short list of paths is always exempt so that infrastructure and on-box tooling are never throttled: the SignalR hub paths, the health endpoint, the anonymous version endpoint, the developer endpoints, and any caller arriving over loopback.

The fact that matters for hardening is the one from step 1: the entire limiter is disabled in Development, which is the shipped default. Setting ASPNETCORE_ENVIRONMENT=Production is what turns it on. Read that in both directions — an unexpected 429 on a developer's machine means the box is not in Development, and an install that has never returned a 429 under load has probably never left it.

Surfaces that answer without a Forge login

Forge deliberately ships three surfaces that respond to someone who has no Forge user account. Each sits behind a capability, so this is a switch, not a fact of the deployment: leave the capability off and the routes stop existing for that install. See Capability Gating for how that gate works, and App Surfaces for where these fit among the front ends.

Surface Capability Who it is for How it authenticates
Customer portal CAP-EXT-CUSTOMER-PORTAL A named contact at a customer Passwordless link, exchanged for a short-lived portal session
Public terms page rides with CAP-O2C-QUOTE The recipient of a quote Unguessable token in the emailed link
Public order acceptance CAP-O2C-SO The customer accepting an order's terms Unguessable link, plus a second verification key to accept

The customer portal is granted per contact from the customer record, not per customer, and is admin-revocable without deleting the contact. The contact's email is the identifier; a sign-in request mints a fresh one-time token that is stored only as a hash, with a short expiry, and the portal exchanges it for a session token carrying portal, customer and contact claims. Only the two authentication endpoints are anonymous — everything the portal displays requires that session.

The public terms page is a minimal server-rendered view of a quote's terms snapshot: no login, no SPA, marked noindex. Unknown or revoked tokens are indistinguishable 404s. Public sales-order acceptance works the same way for reviewing an order, with acceptance requiring a second verification key and recording the accepting party by name. It has an authenticated sibling for customers whose own system posts acceptance programmatically: that one lives on its own controller, accepts a staff JWT or a user-bound system API key, and reaches exactly that endpoint and nothing else on the sales-order surface — see API Access.

Two operational preconditions. All of this depends on working outbound mail: a link that cannot be sent is logged and the request still succeeds, by design, so a contact who never receives one tells you nothing about whether their address is known. And the links must point somewhere the recipient can actually reach: the portal builds its sign-in link from the origin the request arrived on, so a portal enabled on a loopback-only install issues links nobody outside the box can open, and a portal reached through a proxy must be reached on the same public hostname your customers will use — which is also the hostname that has to be in CORS_ORIGINS. All of these routes sit behind the same global rate limiter, which is another reason step 1 comes first.

Reporting a security issue

Do not open a public issue for a security problem. The umbrella repo's CONTRIBUTING.md asks that security reports go privately by email to the maintainer named in CODE_OF_CONDUCT.md, and that is the current route — there is no SECURITY.md and no advertised private-reporting form yet. Ordinary bugs still belong in the repo that owns the code, as Contributing describes; this is the one carve-out.

If you are unsure which you have, treat it as a security issue and send it privately. Reports that include the affected version (the image tags in your .env, or the version endpoint's output) and a description of impact are the ones that can be acted on fastest.

Clone this wiki locally