Give the web server its own .env, where PORT means its port

The previous commit made `pnpm --filter web start` load the repository-root
.env, which was the wrong file: Strapi reads a bare PORT as well
(apps/cms/config/server.ts uses env.int('PORT', 1337)), so one value there
would have moved the CMS onto the website's port the moment anyone exported
it - and the root .env is docker compose's file, whose WEB_PORT is a
published host port, not a listen port.

The web app now loads apps/web/.env, the way Strapi already loads
apps/cms/.env. PORT and HOST are per-application because they mean something
different to each server. apps/web/.env.example documents them together with
SITE_URL, STRAPI_URL and STRAPI_TOKEN, and warns against the VITE_ prefix,
which would inline a value into the client bundle - STRAPI_TOKEN lives in
that file.

Still no default port: with PORT unset, react-router-serve keeps its own
behaviour of taking the first free port from 3000 upwards. A real
environment variable wins over the file, so a systemd unit or a container
that injects PORT is unaffected.

.dockerignore now excludes .env at every depth, not only at the root.
`COPY apps/web ./apps/web` would otherwise have baked a developer's
apps/web/.env - STRAPI_TOKEN included - into the image.

Verified against the existing build: PORT=8140 HOST=127.0.0.1 in
apps/web/.env listens on 127.0.0.1:8140, and PORT=8141 in the environment
overrides the file while HOST still comes from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 11:45:29 +02:00
co-authored by Claude Opus 5
parent e27e31bfa5
commit 2ed3a17914
5 changed files with 110 additions and 70 deletions
+40 -42
View File
@@ -195,12 +195,17 @@ sudo -u postgres psql -c "CREATE ROLE sharper WITH LOGIN PASSWORD 'a-strong-pass
### 4. Environment files
Two files, with different jobs:
Three files, with different jobs:
| File | Read by | Required in manual mode |
| --- | --- | --- |
| `apps/cms/.env` | Strapi (it loads its own `.env`) | **yes** |
| `.env` (root) | `docker compose`, and optionally the web server | no — the code defaults already point at `localhost` |
| `apps/web/.env` | `pnpm --filter web start` (it loads its own `.env`) | no — but it is where `PORT` goes |
| `.env` (root) | `docker compose` only | no |
Each application reads its **own** `.env`, and the root one belongs to `docker compose`.
That split is not cosmetic: `PORT` and `HOST` mean something different to each server, and a
single shared value would put Strapi on the website's port.
```bash
# CMS: copy the fully documented template, then fill in the six secrets
@@ -222,17 +227,19 @@ overwrites a value that is already set unless you pass `--force`, so it is safe
Without `--write` it just prints a block you can paste. By hand:
`openssl rand -base64 32`, once per key (`APP_KEYS` takes at least two, comma separated).
The web application reads `STRAPI_URL`, `STRAPI_TOKEN` and `SITE_URL` from its **process
environment**. Nothing loads the root `.env` for you in manual mode, and you do not need it:
`STRAPI_URL` already defaults to `http://localhost:1337`, and with `SITE_URL` unset the
canonical URL is derived from the incoming request. To override them anyway:
The web application needs nothing to start: `STRAPI_URL` already defaults to
`http://localhost:1337`, and with `SITE_URL` unset the canonical URL is derived from the
incoming request. Copy its template when you want to override those, or to pin the
production port:
```bash
cp .env.example .env
set -a; . ./.env; set +a # export it into the current shell
pnpm dev
cp apps/web/.env.example apps/web/.env # PORT, HOST, SITE_URL, STRAPI_URL, STRAPI_TOKEN
```
`pnpm --filter web start` loads that file. `pnpm dev:web` does not — the dev server is Vite
on 5173 — so export anything you need for development, or put it in the file for the
production server and pass it inline while developing.
### 5. Run both applications
```bash
@@ -469,7 +476,7 @@ docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
| --- | --- |
| `dev` | `react-router dev` — SSR dev server with HMR |
| `build` | `react-router build` → `build/client` (static assets) + `build/server` (SSR bundle) |
| `start` | the production server — `react-router-serve ./build/server/index.js` on `PORT`, falling back to `WEB_PORT`, then 3000 |
| `start` | the production server — `react-router-serve ./build/server/index.js`, started with `node --env-file-if-exists=.env` so `apps/web/.env` can set `PORT` |
| `typecheck` | regenerates `+types/*` route types, then `tsc` |
**`apps/cms`** — run with `pnpm --filter cms <script>`:
@@ -766,58 +773,49 @@ pnpm install --frozen-lockfile
pnpm build # cms (admin panel + dist/) then web (build/client + build/server)
pnpm --filter cms start # strapi start :1337
pnpm --filter web start # react-router-serve :3000
pnpm --filter web start # react-router-serve :$PORT (see below)
```
Both are ordinary long-running Node processes. Run them under systemd, PM2 or the
containers in `docker-compose.yml`, behind a reverse proxy (nginx, Caddy) that terminates
TLS and forwards to 3000 and 1337.
#### Pinning the web server's port and interface
#### Choosing the web server's port
`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:
`react-router-serve` binds `PORT` when it is set, and otherwise takes the first **free** port
from 3000 upwards — so a busy 3000 moves the site to 3001, 3002, … with no error, and the
proxy's upstream stops matching. Nothing in the repository defaults it for you.
`pnpm --filter web start` runs under `node --env-file-if-exists=.env`, so `apps/web/.env` is
enough:
```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
echo 'PORT=8080' >> apps/web/.env
pnpm --filter web start # :8080
```
`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:
It has to be that file and not the root `.env`: Strapi reads a bare `PORT` too, so a shared
one would move the CMS onto the website's port. `apps/web/.env` is read by the web server
alone, exactly as `apps/cms/.env` is read by Strapi alone.
```bash
HOST=127.0.0.1 PORT=8080 pnpm --filter web start
```
A real environment variable wins over the file, so `PORT=9090 pnpm --filter web start` and a
systemd unit's `Environment=PORT=8080` both override it. `HOST` works the same way — unset it
listens on every interface, `HOST=127.0.0.1` only accepts the reverse proxy on the same host.
Under systemd both belong in the unit, where nothing sources `.env` for you:
Under Docker none of this applies: `PORT=3000` is set in the image and in
`docker-compose.yml`, `WEB_PORT` in the root `.env` is the **host** port the container is
published on (`WEB_PORT=8080` gives `8080:3000`), and `.dockerignore` keeps every `.env` out
of the build context.
```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.
Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly`), not
`http://127.0.0.1:8080` — canonical URLs, `hreflang` and JSON-LD are built from it.
### Production environment
| Variable | Where | Value |
| --- | --- | --- |
| `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 |
| `PORT` | web | the port `react-router-serve` binds, e.g. `8080`. Unset, it takes the first free port from 3000 upwards |
| `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 |
| `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 |