Bind the production web server to a chosen port

react-router-serve only honours a port it is given: with PORT unset it calls
getAvailablePort(3000) and takes the first free port from 3000 upwards
(@react-router/serve cli.js). Nothing in the manual path set PORT, so a
busy 3000 silently moved the site to 3001, 3002 and so on, and a reverse
proxy configured against one upstream stopped matching. Docker never showed
this because apps/web/Dockerfile and docker-compose.yml both set PORT=3000.

The web start script now always passes a port - PORT if exported, else
WEB_PORT from .env, else 3000 - so the manual path is as deterministic as
the containerised one and .env's WEB_PORT finally reaches the process that
binds. HOST is deliberately left undefaulted: react-router-serve reads it
directly, unset means every interface (what the container needs) and
127.0.0.1 restricts the socket to a proxy on the same host.

Verified against the existing build: PORT=8123 listens on *:8123, and
WEB_PORT=8124 HOST=127.0.0.1 listens on 127.0.0.1:8124 and serves /en with
a 200 while the CMS is down.

.env.example documents both variables, and the deployment section of
README.md gains a systemd unit, since nothing sources .env there, and a
warning that WEB_PORT is the published host port under Docker but the
listen port in manual mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 11:33:02 +02:00
co-authored by Claude Opus 5
parent 2c51c35fe8
commit e27e31bfa5
3 changed files with 54 additions and 2 deletions
+11
View File
@@ -68,7 +68,18 @@ ENCRYPTION_KEY=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# React Router server (production build / Docker). `pnpm dev:web` uses 5173. # React Router server (production build / Docker). `pnpm dev:web` uses 5173.
# Manual mode: `pnpm --filter web start` binds this port exactly - export it
# first (set -a; . ./.env; set +a) or pass it inline (PORT=8080 pnpm ...).
# Without it the server would pick the first free port from 3000 upwards,
# which a reverse proxy cannot be pointed at.
WEB_PORT=3000 WEB_PORT=3000
# Manual mode only - interface the React Router server binds to. Unset means
# every interface. Set it to 127.0.0.1 when a reverse proxy on the same host
# is the only thing that should reach it. Leave it unset under Docker, where
# the container's own network namespace already isolates it.
# HOST=127.0.0.1
# Strapi API + admin panel. # Strapi API + admin panel.
CMS_PORT=1337 CMS_PORT=1337
+42 -1
View File
@@ -469,7 +469,7 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
| --- | --- | | --- | --- |
| `dev` | `react-router dev` — SSR dev server with HMR | | `dev` | `react-router dev` — SSR dev server with HMR |
| `build` | `react-router build` → `build/client` (static assets) + `build/server` (SSR bundle) | | `build` | `react-router build` → `build/client` (static assets) + `build/server` (SSR bundle) |
| `start` | `react-router-serve ./build/server/index.js` — the production server, port 3000 | | `start` | the production server — `react-router-serve ./build/server/index.js` on `PORT`, falling back to `WEB_PORT`, then 3000 |
| `typecheck` | regenerates `+types/*` route types, then `tsc` | | `typecheck` | regenerates `+types/*` route types, then `tsc` |
**`apps/cms`** — run with `pnpm --filter cms <script>`: **`apps/cms`** — run with `pnpm --filter cms <script>`:
@@ -773,11 +773,52 @@ Both are ordinary long-running Node processes. Run them under systemd, PM2 or th
containers in `docker-compose.yml`, behind a reverse proxy (nginx, Caddy) that terminates containers in `docker-compose.yml`, behind a reverse proxy (nginx, Caddy) that terminates
TLS and forwards to 3000 and 1337. TLS and forwards to 3000 and 1337.
#### Pinning the web server's port and interface
`react-router-serve` only picks a port itself when `PORT` is unset — and it picks the first
free one **from 3000 upwards**, so a busy 3000 silently moves the site to 3001, 3002, … and
the proxy's upstream stops matching. The `start` script therefore always passes a port:
`PORT`, else `WEB_PORT`, else 3000. Choose one explicitly, any of these ways:
```bash
PORT=8080 pnpm --filter web start # one run
set -a; . ./.env; set +a # or export WEB_PORT=8080 from .env once
pnpm --filter web start
```
`HOST` is read by the server directly and is not defaulted. Unset, it listens on every
interface; set it when the reverse proxy on the same host should be the only way in:
```bash
HOST=127.0.0.1 PORT=8080 pnpm --filter web start
```
Under systemd both belong in the unit, where nothing sources `.env` for you:
```ini
[Service]
Environment=NODE_ENV=production HOST=127.0.0.1 PORT=8080
Environment=SITE_URL=https://sharper.ly STRAPI_URL=https://cms.sharper.ly
WorkingDirectory=/srv/sharper
ExecStart=/usr/bin/pnpm --filter web start
```
Under Docker the container always listens on 3000 (`PORT` is set in `apps/web/Dockerfile`
and in `docker-compose.yml`); `WEB_PORT` there is the **host** port it is published on, so
`WEB_PORT=8080` gives `8080:3000` and the proxy points at 8080. Leave `HOST` unset in that
mode — the container's own network namespace is the isolation.
Whichever port you pick, `SITE_URL` must still be the public origin visitors use
(`https://sharper.ly`), not the internal `http://127.0.0.1:8080` — canonical URLs, `hreflang`
and JSON-LD are built from it.
### Production environment ### Production environment
| Variable | Where | Value | | Variable | Where | Value |
| --- | --- | --- | | --- | --- | --- |
| `NODE_ENV` | both | `production` | | `NODE_ENV` | both | `production` |
| `PORT` | web | the port `react-router-serve` binds, e.g. `8080`. Unset it and the script falls back to `WEB_PORT`, then 3000 |
| `HOST` | web | optional; `127.0.0.1` to accept only the local reverse proxy, unset to listen on every interface |
| `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it | | `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it |
| `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server | | `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server |
| `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public | | `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public |
+1 -1
View File
@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"build": "react-router build", "build": "react-router build",
"dev": "react-router dev --host", "dev": "react-router dev --host",
"start": "react-router-serve ./build/server/index.js", "start": "PORT=${PORT:-${WEB_PORT:-3000}} react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc" "typecheck": "react-router typegen && tsc"
}, },
"dependencies": { "dependencies": {