Ship one container image: nginx serves the frontend and proxies /api to the backend in the same container #52

Closed
opened 2026-09-21 09:47:41 +02:00 by clawbot · 1 comment
Collaborator

Why

Owner ruling on #25: one service, one port, one container. Inbound to nginx; nginx serves the built frontend and proxies a route to the Go backend running in the same container. Today there are two images (Dockerfile, Dockerfile.backend) with no network path between them, and nginx.conf has no proxy_pass.

Definition of done

  • One root Dockerfile builds the single shipping image; Dockerfile.backend is deleted. Stages follow the REPO_POLICIES Go pattern: a lint stage on the digest-pinned golangci/golangci-lint image (keep the pin already on next) running the backend format check and lint; a Go build stage that depends on the lint stage via COPY --from=lint, runs the backend tests and builds the static binary with ARG VERSION; a node stage that runs the frontend check and produces dist/; a runtime stage on the digest-pinned nginx alpine image already in use, containing nginx, dist/, the Go binary and the entrypoint. Every base image pinned by digest with a version+date comment.
  • Inside the container the backend listens on loopback only (127.0.0.1) on an internal port that is not the public port. The bind address is a config setting next to PORT in backend/internal/config/config.go (viper SetDefault + AutomaticEnv), documented in backend/README.md.
  • nginx listens on the public port (8080) and proxies /api/ and /.well-known/healthcheck to the backend, setting Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto. Everything else is served from dist/ as today (try_files for / stays; /assets/ caching stays).
  • Process supervision is a small POSIX sh entrypoint: start the backend, start nginx, forward SIGTERM/SIGINT to both, and exit non-zero as soon as either process exits so the platform restarts the container instead of leaving it half-alive. Plain trap, background jobs, wait, explicit PIDs. No third-party supervisor unless the script provably cannot be made correct; any added binary is a hash-verified download, never an unpinned package.
  • Verified and summarised in the PR (no logs): kill the backend inside a running container and the container exits non-zero; same for nginx; docker stop delivers TERM to both, the backend flushes its buffer, exit status 0. Build the image, run it, curl / returns the page, curl -X POST /api/v1/reports with a valid body returns the backend's JSON through nginx, /.well-known/healthcheck answers through nginx.
  • DATA_DIR defaults to a directory under /data in the image, declared with VOLUME, writable by the user the backend runs as. Say in the PR which user each process runs as.
  • script/cibuild, script/docker and .gitea/workflows/check.yml build exactly one image. script/docker becomes byte-identical to the org model at https://git.eeqj.de/sneak/prompts/raw/branch/main/script/docker (the divergence #28 lists; consolidation is the moment to fix it).
  • backend/README.md and the root README.md Deployment section describe the single image: public port, the internal backend port, DATA_DIR and the volume, the proxied paths. Only what changed; #24 does the full rewrite.
  • Root make check green; docker build . green twice in a row on the unchanged tree. TODO.md updated in the same commit. Commit title ends (closes #N).

Implementation requirements

  • Base on current next. If #38 has landed, build on its split (node stage runs make frontend-check; lint and Go stages run the backend/script/* entrypoints). If it has not landed, use what next has and do not reimplement it.
  • Do not add security headers or the PORT-driven nginx listen port here; those are #18 and #26 and land on top. Choose the entrypoint so that #26's envsubst restricted to ${PORT} can be added without redesign, and say how in the PR.
  • Do not touch src/main.js; the reporting client is its own issue.
  • The backend's PORT default stays 8080 for standalone runs; the image passes the internal port explicitly.
  • No scripted edits (sed -i, perl -pi, awk, python rewrites); edit files by hand, format only via make fmt. make targets and script/ entrypoints only. No attribution trailers.

model: claude-fable-5

## Why Owner ruling on https://git.eeqj.de/sneak/netwatch/issues/25: one service, one port, one container. Inbound to nginx; nginx serves the built frontend and proxies a route to the Go backend running in the same container. Today there are two images (`Dockerfile`, `Dockerfile.backend`) with no network path between them, and `nginx.conf` has no `proxy_pass`. ## Definition of done - [ ] One root `Dockerfile` builds the single shipping image; `Dockerfile.backend` is deleted. Stages follow the REPO_POLICIES Go pattern: a lint stage on the digest-pinned `golangci/golangci-lint` image (keep the pin already on `next`) running the backend format check and lint; a Go build stage that depends on the lint stage via `COPY --from=lint`, runs the backend tests and builds the static binary with `ARG VERSION`; a node stage that runs the frontend check and produces `dist/`; a runtime stage on the digest-pinned nginx alpine image already in use, containing nginx, `dist/`, the Go binary and the entrypoint. Every base image pinned by digest with a version+date comment. - [ ] Inside the container the backend listens on loopback only (`127.0.0.1`) on an internal port that is not the public port. The bind address is a config setting next to `PORT` in `backend/internal/config/config.go` (viper `SetDefault` + `AutomaticEnv`), documented in `backend/README.md`. - [ ] nginx listens on the public port (8080) and proxies `/api/` and `/.well-known/healthcheck` to the backend, setting `Host`, `X-Real-IP`, `X-Forwarded-For` and `X-Forwarded-Proto`. Everything else is served from `dist/` as today (`try_files` for `/` stays; `/assets/` caching stays). - [ ] Process supervision is a small POSIX `sh` entrypoint: start the backend, start nginx, forward SIGTERM/SIGINT to both, and exit non-zero as soon as either process exits so the platform restarts the container instead of leaving it half-alive. Plain `trap`, background jobs, `wait`, explicit PIDs. No third-party supervisor unless the script provably cannot be made correct; any added binary is a hash-verified download, never an unpinned package. - [ ] Verified and summarised in the PR (no logs): kill the backend inside a running container and the container exits non-zero; same for nginx; `docker stop` delivers TERM to both, the backend flushes its buffer, exit status 0. Build the image, run it, `curl /` returns the page, `curl -X POST /api/v1/reports` with a valid body returns the backend's JSON through nginx, `/.well-known/healthcheck` answers through nginx. - [ ] `DATA_DIR` defaults to a directory under `/data` in the image, declared with `VOLUME`, writable by the user the backend runs as. Say in the PR which user each process runs as. - [ ] `script/cibuild`, `script/docker` and `.gitea/workflows/check.yml` build exactly one image. `script/docker` becomes byte-identical to the org model at https://git.eeqj.de/sneak/prompts/raw/branch/main/script/docker (the divergence https://git.eeqj.de/sneak/netwatch/issues/28 lists; consolidation is the moment to fix it). - [ ] `backend/README.md` and the root `README.md` Deployment section describe the single image: public port, the internal backend port, `DATA_DIR` and the volume, the proxied paths. Only what changed; https://git.eeqj.de/sneak/netwatch/issues/24 does the full rewrite. - [ ] Root `make check` green; `docker build .` green twice in a row on the unchanged tree. `TODO.md` updated in the same commit. Commit title ends ` (closes #N)`. ## Implementation requirements - Base on current `next`. If https://git.eeqj.de/sneak/netwatch/pulls/38 has landed, build on its split (node stage runs `make frontend-check`; lint and Go stages run the `backend/script/*` entrypoints). If it has not landed, use what `next` has and do not reimplement it. - Do not add security headers or the `PORT`-driven nginx listen port here; those are https://git.eeqj.de/sneak/netwatch/issues/18 and https://git.eeqj.de/sneak/netwatch/issues/26 and land on top. Choose the entrypoint so that #26's `envsubst` restricted to `${PORT}` can be added without redesign, and say how in the PR. - Do not touch `src/main.js`; the reporting client is its own issue. - The backend's `PORT` default stays 8080 for standalone runs; the image passes the internal port explicitly. - No scripted edits (`sed -i`, `perl -pi`, awk, python rewrites); edit files by hand, format only via `make fmt`. `make` targets and `script/` entrypoints only. No attribution trailers. model: claude-fable-5
clawbot added this to the 1.0.0 milestone 2026-09-21 09:47:41 +02:00
clawbot self-assigned this 2026-09-21 09:47:41 +02:00
Author
Collaborator

Brief update: what #38 left on next, which this builds on.

  • Root make lint runs the Go linter only in Docker, by building the lint stage of Dockerfile.backend (--target lint --no-cache --output type=cacheonly). When Dockerfile.backend is deleted, script/lint builds the lint stage of the root Dockerfile the same way; no host path to golangci-lint returns.
  • The node stage runs make frontend-check; the Go stages run the backend's make targets, which shim to backend/script/*; backend/script/build reads VERSION from the environment.
  • script/cibuild and script/docker each build the one image; script/docker byte-identical to the org model.
  • Verification adds one browser check, done in a headless browser in a container: load the page from the running image over plain HTTP and confirm its report reaches the backend through nginx.
  • #26 (PORT for nginx) and #59 (health check, settings checked at start, upaas README section) land on top; do not do them here.

Model: opus-5-5

Brief update: what https://git.eeqj.de/sneak/netwatch/pulls/38 left on `next`, which this builds on. - Root `make lint` runs the Go linter only in Docker, by building the lint stage of `Dockerfile.backend` (`--target lint --no-cache --output type=cacheonly`). When `Dockerfile.backend` is deleted, `script/lint` builds the lint stage of the root `Dockerfile` the same way; no host path to golangci-lint returns. - The node stage runs `make frontend-check`; the Go stages run the backend's `make` targets, which shim to `backend/script/*`; `backend/script/build` reads `VERSION` from the environment. - `script/cibuild` and `script/docker` each build the one image; `script/docker` byte-identical to the org model. - Verification adds one browser check, done in a headless browser in a container: load the page from the running image over plain HTTP and confirm its report reaches the backend through nginx. - https://git.eeqj.de/sneak/netwatch/issues/26 (`PORT` for nginx) and https://git.eeqj.de/sneak/netwatch/issues/59 (health check, settings checked at start, upaas README section) land on top; do not do them here. Model: opus-5-5
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/netwatch#52