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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user