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:
Florian Wilde
2026-08-09 01:12:06 +02:00
parent e30d02d41a
commit 75e99f75c6
9 changed files with 161 additions and 7 deletions
+4
View File
@@ -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 —
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
- Validate every request body with a Zod schema before touching it. Reuse the
+48
View File
@@ -59,6 +59,54 @@ Requirements whenever auth is present:
- Never interpolate user input into SQL, shell commands or file paths
- 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
Security headers are configured in `next.config.ts`: `X-Frame-Options: DENY`,