Before You Deploy
These steps apply whichever way you deploy: Docker, Windows Server or Linux.
What you need first
Section titled “What you need first”-
Node.js 22 or later on your build machine. Installing dependencies stops with an error on an older version.
-
A production build that works on your machine. If it fails locally, it will fail on the server:
Terminal window cd webnpm cinpm run build -
Your production values.
web/.env.examplelists every variable the app needs, and each one needs a production value. See Environment variables below. -
Network access to your backends. If the app talks to a backend API, the server must be able to reach it. On the same machine that’s
localhost. Elsewhere, the server’s firewall must allow outbound HTTPS to it.
Environment variables
Section titled “Environment variables”web/.env.example lists the variables the app needs. They come in two kinds:
| Kind | Example | When the app reads it |
|---|---|---|
| Browser-facing | Names that start with NEXT_PUBLIC_ | At build time. The value is built into the app’s browser code. |
| Server-side | Backend URLs and secrets | At run time, when the server starts |
You don’t have to sort them yourself. One routine covers both: fill in one file, build with it, then put that file on your server.
On your build machine, inside web/:
- Install: run
npm ci. This also createsweb/.env.localfrom.env.exampleif it isn’t there yet. - Fill it in: open
web/.env.localand set the production value for every variable, replacing thelocalhostdefaults. - Build: run
npm run build. The build reads.env.local, so the browser-facing values are built into the app. - Copy it to your server: save
web/.env.localas.envin the deployment folder, next toserver.js. Each platform guide shows where. The standalone server reads a.envfrom its own folder when it starts, so there’s nothing else to set up.
Both .env.local and .env are excluded from git, so your secrets are never committed.
Make sure NEXT_PUBLIC_USE_MOCK_API isn’t set in the file you build with. If it is, the deployed app serves mock data instead of calling your backend.
To change a value later, see Changing an environment variable. A browser-facing value needs a rebuild. A server-side value needs an edit to the server’s .env and a restart.
Docker works differently, because it builds the image in isolation and can’t read your file. Browser-facing values go on the build command, and server-side values are read from web/.env.local at run time. See the Docker guide.
Reverse proxy
Section titled “Reverse proxy”A reverse proxy is a web server that sits in front of your app and passes traffic to it. The browser talks to the proxy on the public port, 443 for HTTPS. The proxy talks to your app on a private port, 3000, that isn’t exposed to the internet.
You need one because the app’s own server doesn’t handle:
- HTTPS: the proxy holds the certificate and decrypts each request before forwarding it
- Friendly hostnames: the proxy maps
app.example.comto the app’s port, so nobody types:3000 - Compression, caching and logging: the proxy handles these for every request
Open inbound ports 80 and 443 in the server’s firewall. Keep the app’s port (3000) and any backend ports closed to the internet.
The proxy must pass three headers, so the app knows what the original request looked like:
| Header | What it tells your app |
|---|---|
Host | The hostname the user typed, such as app.example.com |
X-Forwarded-Proto | Whether the original request was http or https. The app uses this to build correct links and to decide whether to mark cookies as Secure. |
X-Forwarded-For | The real client IP address |
Common choices are nginx on Linux, IIS with the URL Rewrite and ARR modules on Windows Server, and Caddy or Traefik with Docker. Each platform guide shows how to configure the headers.
Cookie domains
Section titled “Cookie domains”This section matters only when the browser talks to a separate sign-in host directly. That happens with sign-in through an external identity provider, or when the browser calls the auth API on another host.
If your sign-in form posts to a path on your own app, which your app forwards to the auth API (the common username and password set-up), the session cookie belongs to your app and none of this applies. See Deploying a Separate Auth API to work out which case you’re in.
When the browser does reach a separate auth host, two cookie properties both have to be right.
1. The same registrable domain decides whether the cookie is sent at all. The registrable domain is the part you buy from a registrar. For both app.example.com and auth.example.com, it’s example.com. Browsers only send sign-in cookies between hosts that share it. Ports don’t count.
| Frontend | Auth endpoint | Same registrable domain? |
|---|---|---|
app.example.com | auth.example.com | Yes, both example.com |
app.example.com | auth.example.net | No, so sign-in fails |
localhost:3000 | localhost:10010 | Yes, because ports don’t count |
If you can’t put both services under one domain, use the reverse proxy to expose the auth endpoint under a path on your main domain. For example, https://app.example.com/auth/ can forward to the real auth service. The browser then only ever sees one domain.
2. The Domain attribute decides which hosts receive the cookie. Sharing the registrable domain isn’t enough on its own. When the auth service sets the cookie, its Domain attribute decides which hosts get it back:
- No
Domain(host-only): the cookie goes only to the exact host that set it, such asauth.example.com. Your app onapp.example.comnever receives it, so its session check finds no cookie and sends the user back to the sign-in page. Domain=example.com: the cookie goes to every subdomain, so both hosts get it. This is what you want, together withSecure,HttpOnlyandSameSite=Strict.
This problem hides in local testing. A host-only cookie set by localhost is sent to localhost on any port, so a missing Domain works locally and only breaks once the two services are on different subdomains. Make the cookie’s Domain an environment setting: unset locally, and the shared parent domain in production. Don’t set Domain=localhost, because several browsers reject it and drop the cookie.
If your project has no sign-in, or uses a username and password form on your own app, skip this section.
What doesn’t ship
Section titled “What doesn’t ship”The workflow’s own files never end up on your server. .claude/, documentation/, generated-docs/ and CLAUDE.md sit outside web/, and none of the deployment paths include them. You don’t need to delete or exclude them before you deploy.