Template
feat: make "only the app publishes a port" a binding rule
A published database port puts the data on the public internet, and Docker's own iptables rules bypass ufw — the server looks firewalled while the port is open. Previously this was only a comment in compose.yaml, which an agent adding a db service later is free to ignore. Anchored at every point where a service could be added or shipped: - rules/security.md: the rule, the reasoning, and the audit commands - rules/backend.md + architecture SKILL: constraint when picking the data layer - agents/backend-dev.md: non-negotiable for any service it adds - agents/qa-engineer.md: a published port is a Critical finding - skills/deploy: blocking pre-flight audit - compose.yaml: db example now uses expose:, with depends_on healthcheck - docs/DEPLOYMENT.md + README: SSH tunnel instead of publishing for debugging
This commit is contained in:
@@ -36,6 +36,10 @@ The non-negotiables:
|
|||||||
as a fallback
|
as a fallback
|
||||||
- Every new env var lands in `.env.example` in the same change
|
- Every new env var lands in `.env.example` in the same change
|
||||||
- Whatever you add must run inside the Docker Compose stack
|
- Whatever you add must run inside the Docker Compose stack
|
||||||
|
- **Any service you add to `compose.yaml` gets no `ports:` entry.** Only `app`
|
||||||
|
publishes a port. A database, cache or queue is reached by service name over the
|
||||||
|
compose network (`db:5432`) and must stay unreachable from the host. Publishing it
|
||||||
|
exposes the data to the internet and bypasses the server's firewall.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
For each route handler, write a Vitest test next to it
|
For each route handler, write a Vitest test next to it
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ The feature spec path and the list of previously deployed features to regression
|
|||||||
fallback rate-limited? A missing or weakened passkey flow is a **High** finding.
|
fallback rate-limited? A missing or weakened passkey flow is a **High** finding.
|
||||||
- Injection through every user-controlled input (XSS, SQL)
|
- Injection through every user-controlled input (XSS, SQL)
|
||||||
- Secrets or internal detail leaking in API responses, logs or the network tab
|
- Secrets or internal detail leaking in API responses, logs or the network tab
|
||||||
|
- **Published ports:** run `docker compose config | grep -B8 "published:"`. Only the
|
||||||
|
`app` service may publish, and only on `127.0.0.1`. A database, cache or queue with
|
||||||
|
a published port — or anything bound to `0.0.0.0` — is a **Critical** finding: it
|
||||||
|
exposes the service to the internet and bypasses the host firewall.
|
||||||
|
|
||||||
## Tests you write
|
## Tests you write
|
||||||
- **Unit (Vitest), co-located in `src/`:** hooks with real logic, pure transforms,
|
- **Unit (Vitest), co-located in `src/`:** hooks with real logic, pure transforms,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ decision exists, do not install an ORM or write schema code.
|
|||||||
Whatever is chosen must be self-hostable inside the Docker Compose stack —
|
Whatever is chosen must be self-hostable inside the Docker Compose stack —
|
||||||
the deployment target is a plain server, not a managed platform.
|
the deployment target is a plain server, not a managed platform.
|
||||||
|
|
||||||
|
When you add it to `compose.yaml`, it gets **no published port**. The app reaches it
|
||||||
|
by service name over the compose network. See the port-exposure rule in
|
||||||
|
`.claude/rules/security.md`.
|
||||||
|
|
||||||
## Route Handlers
|
## Route Handlers
|
||||||
|
|
||||||
- Validate every request body with a Zod schema before touching it. Reuse the
|
- Validate every request body with a Zod schema before touching it. Reuse the
|
||||||
|
|||||||
@@ -59,6 +59,54 @@ Requirements whenever auth is present:
|
|||||||
- Never interpolate user input into SQL, shell commands or file paths
|
- Never interpolate user input into SQL, shell commands or file paths
|
||||||
- Escape or avoid `dangerouslySetInnerHTML`
|
- Escape or avoid `dangerouslySetInnerHTML`
|
||||||
|
|
||||||
|
## Port exposure (mandatory)
|
||||||
|
|
||||||
|
**Only the app service publishes a port. Nothing else. Ever.**
|
||||||
|
|
||||||
|
Databases, caches, queues, search indexes, admin UIs and migration containers get
|
||||||
|
**no `ports:` entry at all**. Services reach each other by service name over the
|
||||||
|
Compose network — `postgres://user:pass@db:5432/app` works without publishing
|
||||||
|
anything. Use `expose:` if you want the port documented; it changes nothing about
|
||||||
|
reachability from outside.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
expose:
|
||||||
|
- "5432" # documentation only — not reachable from the host
|
||||||
|
# ports: # NEVER. This would put the database on the public internet.
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this is not a style preference: `ports: "5432:5432"` on a public server means
|
||||||
|
the only thing between an attacker and the whole dataset is the database password.
|
||||||
|
Worse, Docker writes its own nat/DOCKER-USER iptables rules that **bypass ufw** on a
|
||||||
|
typical setup — the server looks firewalled while the port is wide open. This is one
|
||||||
|
of the most common ways self-hosted stacks get breached.
|
||||||
|
|
||||||
|
The app service itself binds to loopback (`${APP_BIND:-127.0.0.1}`) because a reverse
|
||||||
|
proxy on the host terminates TLS and forwards to it. Binding `0.0.0.0` is only
|
||||||
|
correct when there is no proxy, and it means serving plain HTTP — which also breaks
|
||||||
|
passkeys.
|
||||||
|
|
||||||
|
If a port genuinely must be reachable for local debugging, bind it explicitly to
|
||||||
|
loopback and never bare:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432" # ok for an SSH-tunnelled session
|
||||||
|
- "5432:5432" # WRONG — this is 0.0.0.0, the whole internet
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify what is actually published before deploying:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose config | grep -A3 "ports:" # what the merged config declares
|
||||||
|
docker compose ps --format "{{.Service}}\t{{.Ports}}" # what is really bound
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything other than the app service showing a `0.0.0.0:` binding is a Critical
|
||||||
|
finding — treat it as such in `/qa` and block the deploy.
|
||||||
|
|
||||||
## Transport and Headers
|
## Transport and Headers
|
||||||
|
|
||||||
Security headers are configured in `next.config.ts`: `X-Frame-Options: DENY`,
|
Security headers are configured in `next.config.ts`: `X-Frame-Options: DENY`,
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ This template deliberately ships **no** database or ORM. Decide per feature:
|
|||||||
> es eine Datenbank im Compose-Stack.
|
> es eine Datenbank im Compose-Stack.
|
||||||
|
|
||||||
If a database is needed, also settle the ORM/driver, and record both in the
|
If a database is needed, also settle the ORM/driver, and record both in the
|
||||||
Technical Decisions table. Constraint: it must be self-hostable in the Docker
|
Technical Decisions table. Two constraints: it must be self-hostable in the Docker
|
||||||
Compose stack — no managed-only services.
|
Compose stack (no managed-only services), and it runs **without a published port** —
|
||||||
|
only the app is reachable from outside, everything else talks over the internal
|
||||||
|
compose network.
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ Then check:
|
|||||||
- [ ] Pending migrations are committed
|
- [ ] Pending migrations are committed
|
||||||
- [ ] Everything committed and pushed to the remote the server pulls from
|
- [ ] Everything committed and pushed to the remote the server pulls from
|
||||||
|
|
||||||
|
**Port audit — blocking.** Only the app service may publish a port, and only on
|
||||||
|
loopback:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose config | grep -B8 "published:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Any service other than `app` with a published port, or any binding to `0.0.0.0`,
|
||||||
|
stops the deploy. A published database port puts the data on the public internet and
|
||||||
|
bypasses ufw, because Docker writes its own iptables rules. Fix `compose.yaml`
|
||||||
|
before continuing.
|
||||||
|
|
||||||
## 3. First deployment only
|
## 3. First deployment only
|
||||||
|
|
||||||
Walk the user through this once. It happens on the server, over SSH.
|
Walk the user through this once. It happens on the server, over SSH.
|
||||||
|
|||||||
@@ -435,6 +435,37 @@ docker compose up -d --build # Erststart
|
|||||||
./scripts/update.sh # neuen Stand ausrollen
|
./scripts/update.sh # neuen Stand ausrollen
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Nur der App-Port geht nach außen
|
||||||
|
|
||||||
|
Im Stack veröffentlicht **ausschließlich der App-Service** einen Port, und auch den
|
||||||
|
nur auf Loopback (`127.0.0.1`). Jeder weitere Service — Datenbank, Cache, Queue,
|
||||||
|
Admin-UI — bekommt **keinen `ports:`-Eintrag**. Die Container erreichen sich über
|
||||||
|
den Compose-Netzwerknamen, dafür muss nichts veröffentlicht werden:
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgres://user:pass@db:5432/app
|
||||||
|
```
|
||||||
|
|
||||||
|
> `ports: "5432:5432"` bindet auf `0.0.0.0` — die Datenbank steht damit im offenen
|
||||||
|
> Internet, geschützt nur durch ihr Passwort. Und Docker schreibt eigene
|
||||||
|
> iptables-Regeln, die **ufw umgehen**: Der Server sieht abgeschottet aus, während
|
||||||
|
> der Port offen ist. Das ist einer der häufigsten Wege, wie selbst gehostete Stacks
|
||||||
|
> kompromittiert werden.
|
||||||
|
|
||||||
|
Die Regel ist an vier Stellen verankert, damit sie beim Vibe-Coden nicht verloren
|
||||||
|
geht: `rules/security.md` (lädt automatisch beim Bearbeiten von `compose.yaml`), der
|
||||||
|
Backend-Sub-Agent, der QA-Sub-Agent (Verstoß = **Critical**) und der Pre-Flight von
|
||||||
|
`/deploy`, der den Deploy abbricht.
|
||||||
|
|
||||||
|
Prüfen, was wirklich gebunden ist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose config | grep -B8 "published:"
|
||||||
|
docker compose ps --format "{{.Service}}\t{{.Ports}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Zugriff zum Debuggen holst du dir per SSH-Tunnel, nicht per veröffentlichtem Port.
|
||||||
|
|
||||||
### Was `update.sh` macht
|
### Was `update.sh` macht
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -555,6 +586,7 @@ Vier Dinge, die nicht offensichtlich sind und je einmal wehtun:
|
|||||||
|
|
||||||
| Fall | Was passiert |
|
| Fall | Was passiert |
|
||||||
|------|--------------|
|
|------|--------------|
|
||||||
|
| Ein DB-Service bekommt `ports: "5432:5432"` | Die Datenbank steht im offenen Internet — und Docker umgeht dabei ufw, der Server *wirkt* nur abgeschottet |
|
||||||
| `NEXT_PUBLIC_APP_URL` weicht von der Domain ab | Alle Passkeys scheitern, ohne verwertbare Fehlermeldung |
|
| `NEXT_PUBLIC_APP_URL` weicht von der Domain ab | Alle Passkeys scheitern, ohne verwertbare Fehlermeldung |
|
||||||
| Port 3000 ist von einem anderen Projekt belegt | Playwright testet still gegen den fremden Server — `E2E_PORT` setzen |
|
| Port 3000 ist von einem anderen Projekt belegt | Playwright testet still gegen den fremden Server — `E2E_PORT` setzen |
|
||||||
| Jemand legt eine `tailwind.config.ts` an | Wird in v4 ignoriert; Tokens gehören in `globals.css` |
|
| Jemand legt eine `tailwind.config.ts` an | Wird in v4 ignoriert; Tokens gehören in `globals.css` |
|
||||||
|
|||||||
+20
-2
@@ -32,8 +32,13 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# A database is deliberately not part of this template — /architecture picks one
|
# A database is deliberately not part of this template — /architecture picks one
|
||||||
# per project. When you add one, declare it here as a second service, put it on
|
# per project.
|
||||||
# an internal network without a published port, and add its volume below.
|
#
|
||||||
|
# RULE FOR EVERY SERVICE ADDED BELOW: only `app` gets a `ports:` entry. Databases,
|
||||||
|
# caches and queues get none — the app reaches them by service name over this
|
||||||
|
# compose network (`db:5432`). Publishing 5432 puts the database on the public
|
||||||
|
# internet, and Docker's iptables rules bypass ufw, so the server only *looks*
|
||||||
|
# firewalled. See .claude/rules/security.md.
|
||||||
#
|
#
|
||||||
# db:
|
# db:
|
||||||
# image: postgres:17-alpine
|
# image: postgres:17-alpine
|
||||||
@@ -41,8 +46,21 @@ services:
|
|||||||
# environment:
|
# environment:
|
||||||
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
# POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||||
# POSTGRES_DB: ${POSTGRES_DB:-app}
|
# POSTGRES_DB: ${POSTGRES_DB:-app}
|
||||||
|
# expose:
|
||||||
|
# - "5432" # documentation only — NOT reachable from the host
|
||||||
# volumes:
|
# volumes:
|
||||||
# - db-data:/var/lib/postgresql/data
|
# - db-data:/var/lib/postgresql/data
|
||||||
|
# healthcheck:
|
||||||
|
# test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
# interval: 10s
|
||||||
|
# timeout: 5s
|
||||||
|
# retries: 5
|
||||||
|
#
|
||||||
|
# Then make the app wait for it:
|
||||||
|
# app:
|
||||||
|
# depends_on:
|
||||||
|
# db:
|
||||||
|
# condition: service_healthy
|
||||||
#
|
#
|
||||||
# volumes:
|
# volumes:
|
||||||
# db-data:
|
# db-data:
|
||||||
|
|||||||
+33
-3
@@ -82,14 +82,44 @@ to `HEALTH_TIMEOUT` seconds (default 120). If the container never turns healthy
|
|||||||
prints the last 50 log lines, resets to the previous commit, rebuilds and restarts
|
prints the last 50 log lines, resets to the previous commit, rebuilds and restarts
|
||||||
that — so a bad deploy self-heals instead of leaving the site down.
|
that — so a bad deploy self-heals instead of leaving the site down.
|
||||||
|
|
||||||
|
## Port exposure
|
||||||
|
|
||||||
|
**Only the app service publishes a port, and only on loopback.** Everything else —
|
||||||
|
database, cache, queue, admin UI — gets no `ports:` entry. Services reach each other
|
||||||
|
by name over the Compose network, which needs nothing published:
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgres://user:pass@db:5432/app
|
||||||
|
```
|
||||||
|
|
||||||
|
`ports: "5432:5432"` binds `0.0.0.0`, which means the database is on the public
|
||||||
|
internet. And Docker writes its own nat/DOCKER-USER iptables rules, so **ufw does not
|
||||||
|
block it** — the server looks firewalled while the port is open. This is a routine
|
||||||
|
way self-hosted stacks get breached.
|
||||||
|
|
||||||
|
Need access for debugging? Do not publish the port. Use an SSH tunnel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -L 5432:localhost:5432 user@server \
|
||||||
|
-t 'docker compose -f /srv/app/compose.yaml exec db psql -U postgres'
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit what is really bound before and after every deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose config | grep -B8 "published:" # declared
|
||||||
|
docker compose ps --format "{{.Service}}\t{{.Ports}}" # actual
|
||||||
|
ss -tlnp | grep -v 127.0.0.1 # what the host exposes
|
||||||
|
```
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
The template ships none by design. When `/architecture` picks one:
|
The template ships none by design. When `/architecture` picks one:
|
||||||
|
|
||||||
1. Add it as a second service in `compose.yaml` — no published port, reachable only
|
1. Add it as a second service in `compose.yaml` — **no `ports:` entry**, reachable
|
||||||
over the internal Compose network
|
only over the internal Compose network. Use `expose:` if you want it documented.
|
||||||
2. Add a named volume for its data
|
2. Add a named volume for its data
|
||||||
3. Add `DATABASE_URL` to `.env.example`
|
3. Add `DATABASE_URL` to `.env.example` (pointing at the service name, e.g. `db:5432`)
|
||||||
4. Run migrations **before** the new container serves traffic. Either add a
|
4. Run migrations **before** the new container serves traffic. Either add a
|
||||||
short-lived `migrate` service that the app depends on, or add the migration
|
short-lived `migrate` service that the app depends on, or add the migration
|
||||||
command to `scripts/update.sh` between build and restart.
|
command to `scripts/update.sh` between build and restart.
|
||||||
|
|||||||
Reference in New Issue
Block a user