577 lines
23 KiB
Markdown
577 lines
23 KiB
Markdown
# Xtablo backend
|
||
|
||
Go + HTMX + Postgres. Phase 1: Walking Skeleton.
|
||
|
||
This README is the contract for FOUND-05: a developer with the prerequisites below
|
||
should be able to clone the repo, follow the Quickstart, and see the HTMX-driven
|
||
page within ~5 minutes.
|
||
|
||
## Prerequisites
|
||
|
||
Install these on your dev machine before starting:
|
||
|
||
- **Go** ≥ 1.22 (this project's `go.mod` declares 1.26)
|
||
- **just** — task runner (`brew install just` on macOS, `cargo install just`, or see
|
||
<https://github.com/casey/just>)
|
||
- **podman** with `podman compose` (preferred per D-11) **or** **docker** with
|
||
`docker compose`
|
||
- **curl**
|
||
- **git**
|
||
|
||
You do **not** need to install `goose`, `templ`, `sqlc`, `air`, the Tailwind CLI, or
|
||
`htmx.min.js` — `just bootstrap` installs the Go tools into `$GOBIN` and
|
||
bootstrap-downloads the Tailwind binary and HTMX script into local, gitignored
|
||
paths.
|
||
|
||
## Quickstart
|
||
|
||
Clone-to-running-page in ~5 minutes. Run from inside `backend/`.
|
||
|
||
```
|
||
cd backend
|
||
cp .env.example .env # adjust DATABASE_URL if Postgres is not on localhost:5432
|
||
just bootstrap # installs goose/templ/sqlc/air; bootstrap-downloads tailwindcss + htmx.min.js
|
||
just db-up # starts postgres via podman compose (see fallback below)
|
||
just migrate up # applies migrations from ./migrations
|
||
just dev # terminal 1: brings up db, runs generate, then air on :8080
|
||
|
||
# in a SECOND terminal:
|
||
just styles-watch # rebuilds static/tailwind.css on .templ / .go changes
|
||
|
||
# open http://localhost:8080
|
||
```
|
||
|
||
The page should render with a "Fetch server time" button. Clicking it swaps an
|
||
ISO-8601 timestamp into the page via HTMX. If the page shows "No time fetched
|
||
yet." and nothing happens on click, see Troubleshooting.
|
||
|
||
`bootstrap` is the slowest step (Go tool installs + two HTTP downloads). It only
|
||
needs to run once per clone.
|
||
|
||
## docker compose fallback
|
||
|
||
`compose.yaml` is portable across podman and docker — the service definition is
|
||
identical. If you don't have podman:
|
||
|
||
- Replace `podman compose` with `docker compose` mentally throughout this README.
|
||
- The `just db-up` / `just db-down` recipes call `podman compose` directly. Run
|
||
`docker compose up -d postgres` / `docker compose down` instead, and continue
|
||
with the rest of the Quickstart unchanged.
|
||
|
||
(Decision D-11.)
|
||
|
||
## Project layout
|
||
|
||
```
|
||
backend/
|
||
cmd/
|
||
web/main.go # HTTP server entry point
|
||
worker/main.go # background worker — river periodic jobs (Phase 6)
|
||
internal/
|
||
db/ # pgxpool wiring + sqlc-generated queries
|
||
web/ # chi router, handlers, middleware, design-system
|
||
ui/ # custom templ component library (Button, Card, Badge)
|
||
session/ # placeholder — Phase 2
|
||
tablos/ # placeholder — Phase 3
|
||
tasks/ # placeholder — Phase 4
|
||
files/ # placeholder — Phase 5
|
||
migrations/ # goose .sql migrations
|
||
templates/ # .templ files (layout, index, fragments)
|
||
static/
|
||
htmx.min.js # bootstrap-downloaded by `just bootstrap`; gitignored; no runtime CDN
|
||
tailwind.css # generated by the Tailwind standalone CLI
|
||
bin/ # gitignored — tailwindcss CLI binary, etc.
|
||
.air.toml # air live-reload config
|
||
.env.example # committed; copy to .env
|
||
compose.yaml # local Postgres
|
||
go.mod / go.sum
|
||
justfile # task runner recipes — the source of truth for commands
|
||
sqlc.yaml
|
||
tailwind.input.css
|
||
README.md
|
||
```
|
||
|
||
HTMX is served from `/static/htmx.min.js` at runtime — no CDN. The justfile's
|
||
bootstrap-time `unpkg.com` URL is the single authoritative version pin (D-10).
|
||
|
||
## Environment variables
|
||
|
||
`backend/.env` is gitignored; `backend/.env.example` is committed and lists the
|
||
keys consumed by `cmd/web` and `cmd/worker`. Local Just recipes load
|
||
`backend/.env` automatically, so `just dev` will pick up provider credentials
|
||
such as `GOOGLE_CLIENT_ID`.
|
||
|
||
| Variable | Description | Default |
|
||
| ------------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------- |
|
||
| `DATABASE_URL` | Postgres DSN used by the web + worker binaries and by `just migrate` | `postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable` |
|
||
| `PORT` | HTTP port for `cmd/web` | `8080` |
|
||
| `ENV` | `development` enables slog's text handler; `production` switches to JSON | `development` |
|
||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | blank |
|
||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | blank |
|
||
| `GOOGLE_REDIRECT_URL` | Google callback URL, usually `/auth/google/callback` | `http://localhost:8080/auth/google/callback` |
|
||
|
||
Google config is optional in local development. When it is missing, the login
|
||
and signup pages keep the Google button visible but disabled with a
|
||
not-configured label. No real provider secrets should be committed to
|
||
`.env.example`. Apple sign-in is disabled in the current product surface.
|
||
|
||
## Common commands
|
||
|
||
Every command in this table is a recipe in `backend/justfile`.
|
||
|
||
| Recipe | What it does | When to use |
|
||
| ----------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||
| `just bootstrap` | Installs Go CLI tools (`goose`, `templ`, `sqlc`, `air`); bootstrap-downloads `bin/tailwindcss` and `static/htmx.min.js` | Once per clone; re-run after deleting `bin/` or `static/htmx.min.js` |
|
||
| `just db-up` | Starts the local Postgres container | Before `just migrate up` / `just dev` if not already running |
|
||
| `just db-down` | Stops the local Postgres container | When you're done for the day |
|
||
| `just migrate up` / `migrate down` / `migrate status` | Applies / reverts / inspects goose migrations against `DATABASE_URL` | After `just db-up`, or any time you change `migrations/` |
|
||
| `just generate` | One-shot: `templ generate`, `sqlc generate`, Tailwind compile to `static/tailwind.css` | After editing `.templ`, query SQL, or `tailwind.input.css` |
|
||
| `just styles-watch` | Tailwind standalone CLI in `--watch` mode | In a second terminal alongside `just dev` (D-14) |
|
||
| `just dev` | Loads `backend/.env`, brings up Postgres, runs `just generate`, then runs `air` for Go live-reload on `:8080` | Main dev loop, terminal 1 |
|
||
| `just test` | `templ generate` then `go test ./...` | Before committing |
|
||
| `just lint` | `go vet ./...` and `gofmt -l` check | Before committing |
|
||
| `just build` | Generates assets, then builds `bin/web` and `bin/worker` | Producing release binaries locally |
|
||
| `just clean` | Removes `bin/`, `tmp/`, `static/htmx.min.js`, `static/tailwind.css`, and `*_templ.go` files | Reset to a fresh-clone state without dropping the Postgres volume |
|
||
|
||
## Running the Worker
|
||
|
||
`cmd/worker` is the background job processor. It runs river periodic jobs against
|
||
the same Postgres as `cmd/web`. Start it with:
|
||
|
||
```
|
||
just worker
|
||
```
|
||
|
||
This requires `just db-up` (handled automatically as a dependency) and MinIO
|
||
running (used by the orphan-file cleanup job). If MinIO is not running, the worker
|
||
will exit on startup with "file store init failed".
|
||
|
||
### What to expect
|
||
|
||
- Structured logs appear immediately at startup.
|
||
- A `"worker ready"` log line appears within a few seconds after `rivermigrate`
|
||
and S3 init complete.
|
||
- A `"worker heartbeat"` log line appears almost immediately (the heartbeat job
|
||
is configured with `RunOnStart: true`, so it fires on the first scheduler tick
|
||
which happens within seconds of startup).
|
||
- Subsequent heartbeat logs appear every ~1 minute.
|
||
- The orphan-file cleanup job runs every hour (no `RunOnStart` — first run is
|
||
~1 hour after startup).
|
||
|
||
### Single-worker constraint
|
||
|
||
**Run only one worker process at a time (v1).** River uses advisory locks for
|
||
leader election and concurrent rivermigrate runs are unsafe. Do not run multiple
|
||
worker instances against the same database in this version.
|
||
|
||
### Graceful shutdown
|
||
|
||
Send SIGINT (Ctrl+C) and observe:
|
||
|
||
```
|
||
{"level":"INFO","msg":"shutting down"}
|
||
{"level":"INFO","msg":"shutdown complete"}
|
||
```
|
||
|
||
The worker calls `riverClient.StopAndCancel` with a 10-second timeout, which
|
||
cancels in-flight job contexts and waits for goroutines to exit before closing
|
||
the pool.
|
||
|
||
### Observing failed job retries
|
||
|
||
River logs each failure via the `SlogErrorHandler`. A failed job produces a log
|
||
line like:
|
||
|
||
```
|
||
{"level":"ERROR","msg":"job error","job_id":42,"job_kind":"heartbeat","attempt":1,"max_attempts":25,"err":"..."}
|
||
```
|
||
|
||
River retries up to 25 times with exponential backoff (`attempts^4` + jitter).
|
||
After 25 failed attempts the job is moved to the discarded state in `river_job`.
|
||
|
||
## Troubleshooting
|
||
|
||
The three issues most likely to trip you up on a fresh clone:
|
||
|
||
- **"Fresh clone fails to build with `undefined: templates.Index`"** — Templ
|
||
generates `*_templ.go` files from `.templ` sources, and those generated files
|
||
are not committed. Run `just generate` (or `just dev`, which calls it) before
|
||
invoking `go build` directly. (Pitfall 1.)
|
||
|
||
- **"First request to `/healthz` returns 503 right after `just db-up`"** — The
|
||
Postgres container needs ~5–10 seconds to become healthy after `podman compose
|
||
up -d` returns. Check `podman compose ps` (or `docker compose ps`) for the
|
||
`healthy` status, or just wait and retry. Subsequent calls succeed. The 503
|
||
during warm-up is correct behavior, not a bug. (Pitfall 2.)
|
||
|
||
- **"Tailwind classes used in `.templ` files don't appear in the compiled CSS"** —
|
||
Tailwind v4 only scans content paths declared via `@source` in
|
||
`tailwind.input.css`. Confirm the file contains `@source
|
||
"../templates/**/*.templ";` (and equivalent globs for `internal/web/**/*.go`).
|
||
Re-run `just styles-watch` so the watcher picks up the config change.
|
||
(Pitfall 3.)
|
||
|
||
If something else is wrong and you want a clean slate without dropping the
|
||
Postgres volume:
|
||
|
||
```
|
||
just clean # removes bin/, tmp/, static/htmx.min.js, static/tailwind.css, *_templ.go
|
||
just bootstrap # re-download tools and assets
|
||
just dev # back to a working state
|
||
```
|
||
|
||
Run `just db-down` first if you also want to drop the Postgres container.
|
||
|
||
## What Phase 1 ships (and doesn't)
|
||
|
||
**Ships:**
|
||
|
||
- Project scaffold (`go.mod`, justfile, `.air.toml`, `tailwind.input.css`,
|
||
`sqlc.yaml`, `compose.yaml`)
|
||
- Local Postgres via `compose.yaml` (`pg_isready` healthcheck)
|
||
- goose migration pipeline (`migrations/0001_init.sql` is a no-op bootstrap)
|
||
- chi router with `/`, `/healthz`, `/demo/time`, `/static/*`
|
||
- slog-based structured logging with RequestID middleware
|
||
- Graceful HTTP shutdown
|
||
- pgxpool wiring exercised by `/healthz`
|
||
- templ + HTMX demo (root page + `hx-get` round-trip to a templ fragment)
|
||
- Custom templ design-system package at `internal/web/ui/` (Button, Card, Badge)
|
||
- Live-reload dev loop (`just dev` + `just styles-watch`)
|
||
- `cmd/worker` skeleton (boot, log, idle, shutdown)
|
||
|
||
**Does not ship — deferred:**
|
||
|
||
- Authentication, sessions, users → Phase 2
|
||
- Tablos CRUD → Phase 3
|
||
- Tasks / kanban → Phase 4
|
||
- File uploads + R2/S3 → Phase 5
|
||
- Real worker jobs → Phase 6
|
||
- Production deploy, Dockerfile, `/readyz` → Phase 7
|
||
|
||
## Deploy
|
||
|
||
The production host is a Hetzner VM running plain Docker Compose (D-01, D-02). No
|
||
Kubernetes or managed orchestration is needed — `docker compose up -d` on the VM is
|
||
the entire deployment mechanism. Postgres runs inside the compose stack (D-03); there
|
||
is no external managed database.
|
||
|
||
### Prerequisites
|
||
|
||
Install on the production VM before first deploy:
|
||
|
||
- **Docker** ≥ 24 with the **Docker Compose** plugin (`docker compose` — not the
|
||
standalone `docker-compose` binary)
|
||
- **git** (optional — useful for pulling the repo directly onto the VM)
|
||
|
||
No other runtimes are needed. Go, Node, and all build tooling run in the Dockerfile's
|
||
multi-stage build and are not required on the VM.
|
||
|
||
### First-time setup
|
||
|
||
Run all commands on the VM via SSH unless noted otherwise.
|
||
|
||
1. **SSH to the VM.**
|
||
|
||
```
|
||
ssh user@<vm-ip>
|
||
```
|
||
|
||
2. **Copy the `backend/` directory to the VM** (or clone the repo).
|
||
|
||
```
|
||
# Option A — rsync from local machine:
|
||
rsync -av --exclude '.git' backend/ user@<vm-ip>:~/xtablo/
|
||
|
||
# Option B — clone the repo directly on the VM:
|
||
git clone <repo-url> ~/xtablo && cd ~/xtablo/backend
|
||
```
|
||
|
||
3. **Create `.env.prod`** by copying `.env.example` and filling in real values.
|
||
|
||
```
|
||
cp .env.example .env.prod
|
||
chmod 600 .env.prod # restrict read access — file contains secrets (T-07-10)
|
||
```
|
||
|
||
Mandatory variables to set in `.env.prod`:
|
||
|
||
| Variable | Value |
|
||
|---|---|
|
||
| `DATABASE_URL` | `postgres://xtablo:<POSTGRES_PASSWORD>@postgres:5432/xtablo?sslmode=disable` (internal compose network — hostname is `postgres`) |
|
||
| `POSTGRES_PASSWORD` | Strong random password (also used by the postgres service). Example: `openssl rand -hex 24` |
|
||
| `POSTGRES_USER` | `xtablo` (or your custom user; must match `DATABASE_URL`) |
|
||
| `POSTGRES_DB` | `xtablo` (or your custom db; must match `DATABASE_URL`) |
|
||
| `SESSION_SECRET` | 32 random bytes hex-encoded. Generate with: `openssl rand -hex 32` |
|
||
| `S3_ENDPOINT` | R2 endpoint URL: `https://<account-id>.r2.cloudflarestorage.com` |
|
||
| `S3_BUCKET` | R2 bucket name |
|
||
| `S3_ACCESS_KEY` | R2 API token key ID |
|
||
| `S3_SECRET_KEY` | R2 API token secret |
|
||
| `S3_USE_PATH_STYLE` | `false` for Cloudflare R2 (virtual-hosted-style URLs) |
|
||
| `S3_REGION` | `auto` or `us-east-1` (R2 accepts both) |
|
||
| `MAX_UPLOAD_SIZE_MB` | `25` (or your preferred limit) |
|
||
| `ENV` | `production` (activates JSON slog handler) |
|
||
| `PORT` | `8080` |
|
||
| `DOMAIN` | `app.yourdomain.com` (Caddy reads this for TLS) |
|
||
|
||
Do **not** include `TEST_DATABASE_URL` in `.env.prod` — it is a dev/test-only
|
||
variable and is not used by the runtime binaries.
|
||
|
||
4. **Build the Docker image** (from inside `backend/` — either locally or on the VM).
|
||
|
||
```
|
||
# From inside backend/
|
||
docker build -f Dockerfile -t ghcr.io/yourusername/xtablo:v0.1.0 .
|
||
```
|
||
|
||
If building locally, push to a registry and pull on the VM:
|
||
|
||
```
|
||
docker push ghcr.io/yourusername/xtablo:v0.1.0
|
||
# On the VM:
|
||
docker pull ghcr.io/yourusername/xtablo:v0.1.0
|
||
```
|
||
|
||
5. **Set image coordinates as environment variables** (used by `docker-compose.prod.yaml`).
|
||
|
||
```
|
||
export IMAGE=ghcr.io/yourusername/xtablo
|
||
export TAG=v0.1.0
|
||
```
|
||
|
||
6. **Start the stack.**
|
||
|
||
```
|
||
docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d
|
||
```
|
||
|
||
The postgres service must pass its healthcheck before web and worker start.
|
||
Migrations run automatically at web startup via `goose.Up()` (D-10).
|
||
|
||
7. **Verify the deployment.**
|
||
|
||
```
|
||
curl https://app.yourdomain.com/healthz # → {"status":"ok"}
|
||
curl https://app.yourdomain.com/readyz # → {"status":"ok","db":"ok"}
|
||
```
|
||
|
||
If the domain is not yet configured, use the VM's public IP temporarily with
|
||
HTTP (Caddy will not yet have a certificate):
|
||
|
||
```
|
||
curl http://<vm-ip>:80/healthz
|
||
```
|
||
|
||
8. **Let's Encrypt staging (for initial TLS testing).**
|
||
|
||
To avoid hitting Let's Encrypt production rate limits (5 duplicate certificates
|
||
per week per domain) during initial setup, uncomment the staging global block in
|
||
`deploy/Caddyfile`:
|
||
|
||
```
|
||
{
|
||
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
|
||
}
|
||
```
|
||
|
||
Restart Caddy after editing (`docker compose -f docker-compose.prod.yaml restart caddy`),
|
||
verify TLS works (browsers will show a staging cert warning — that is expected),
|
||
then remove the global block and clear the `caddy_data` volume to issue a real
|
||
production certificate.
|
||
|
||
### Deploying a new version
|
||
|
||
1. **Build and tag the new image** (same as first-time, with a new tag):
|
||
|
||
```
|
||
docker build -f Dockerfile -t ghcr.io/yourusername/xtablo:v0.2.0 .
|
||
docker push ghcr.io/yourusername/xtablo:v0.2.0 # if using a registry
|
||
```
|
||
|
||
2. **On the VM** — update `TAG` in `.env.prod`:
|
||
|
||
```
|
||
# Edit .env.prod:
|
||
TAG=v0.2.0
|
||
```
|
||
|
||
Or pass it inline without editing the file:
|
||
|
||
```
|
||
export TAG=v0.2.0
|
||
```
|
||
|
||
3. **Pull and recreate only the changed services:**
|
||
|
||
```
|
||
docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d
|
||
```
|
||
|
||
Compose recreates only the web and worker containers (their image tag changed).
|
||
Postgres and Caddy are unaffected. Migrations run automatically at web startup
|
||
(D-10) — `goose.Up()` is idempotent and skips already-applied migrations.
|
||
|
||
## Rollback
|
||
|
||
Rollback means redeploying the previous image tag (D-11). No special tooling is
|
||
required — it is the same as deploying a new version, but with an older tag.
|
||
|
||
1. **On the VM** — set `TAG` to the previous tag in `.env.prod` (or inline):
|
||
|
||
```
|
||
export TAG=v0.1.0
|
||
```
|
||
|
||
2. **Redeploy:**
|
||
|
||
```
|
||
docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d
|
||
```
|
||
|
||
Compose recreates web and worker with the old image. The rollback is complete.
|
||
|
||
### Schema rollback (break-glass)
|
||
|
||
`goose.Up()` is idempotent — rolling back to a previous binary does not automatically
|
||
run `goose down`. In most cases this is fine: the old binary ignores columns it does
|
||
not know about.
|
||
|
||
If a migration introduced a schema change that is **incompatible** with the old binary
|
||
(e.g. a NOT NULL column without a default that the old binary does not supply), run a
|
||
manual goose down as a break-glass step:
|
||
|
||
1. Connect to Postgres inside the container:
|
||
|
||
```
|
||
docker exec -it <postgres-container-name> psql -U xtablo -d xtablo
|
||
```
|
||
|
||
(Find the container name with `docker compose -f docker-compose.prod.yaml ps`.)
|
||
|
||
2. The production image is distroless — the `goose` CLI is not inside the runtime
|
||
container. Install the goose CLI separately on the VM or use the goose Docker
|
||
image against the internal network:
|
||
|
||
```
|
||
# Install goose CLI on the VM:
|
||
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||
goose -dir ./migrations postgres "$DATABASE_URL" down
|
||
```
|
||
|
||
Or use an ephemeral container on the same compose network:
|
||
|
||
```
|
||
docker run --rm --network <compose-network> \
|
||
-e GOOSE_DRIVER=postgres \
|
||
-e GOOSE_DBSTRING="postgres://xtablo:<password>@postgres:5432/xtablo?sslmode=disable" \
|
||
-v $(pwd)/migrations:/migrations \
|
||
ghcr.io/kukymbr/goose-docker:latest \
|
||
goose -dir /migrations down
|
||
```
|
||
|
||
After reverting the migration, the old binary will start cleanly.
|
||
|
||
## Incident Runbook
|
||
|
||
### /readyz returns 503
|
||
|
||
`/readyz` pings Postgres. A 503 means the web container cannot reach the database.
|
||
|
||
1. Check container status:
|
||
|
||
```
|
||
docker compose -f docker-compose.prod.yaml ps
|
||
```
|
||
|
||
2. If `postgres` is down or unhealthy, restart it:
|
||
|
||
```
|
||
docker compose -f docker-compose.prod.yaml up -d postgres
|
||
```
|
||
|
||
Then restart web and worker (they will wait for postgres to be healthy):
|
||
|
||
```
|
||
docker compose -f docker-compose.prod.yaml up -d
|
||
```
|
||
|
||
3. Check web logs for the actual error:
|
||
|
||
```
|
||
docker compose -f docker-compose.prod.yaml logs web --tail=50
|
||
```
|
||
|
||
All application logs are JSON when `ENV=production` is set. Look for
|
||
`"level":"ERROR"` lines with a `"msg":"db ping failed"` or similar.
|
||
|
||
### Caddy TLS certificate errors
|
||
|
||
1. Check caddy logs:
|
||
|
||
```
|
||
docker compose -f docker-compose.prod.yaml logs caddy --tail=50
|
||
```
|
||
|
||
2. If you see "too many certificates already issued for" (Let's Encrypt rate limit,
|
||
RESEARCH Pitfall 4):
|
||
- Caddy hit the 5 duplicate certificates per week limit for the domain.
|
||
- Confirm the `caddy_data` named volume exists and is mounted — if the volume was
|
||
accidentally deleted, Caddy cannot reuse the cached certificate and must
|
||
re-issue on every restart, quickly exhausting the rate limit.
|
||
- Recovery options:
|
||
- Wait up to 1 week for the rate limit window to reset.
|
||
- Switch to the Let's Encrypt staging endpoint temporarily (see
|
||
"Let's Encrypt staging" in the First-time setup section above).
|
||
- Restore from a `caddy_data` volume backup if available.
|
||
|
||
3. If the `caddy_data` volume was lost:
|
||
|
||
```
|
||
# Verify the volume still exists:
|
||
docker volume ls | grep caddy_data
|
||
|
||
# If missing, the volume must be recreated (certificates will be re-issued):
|
||
docker compose -f docker-compose.prod.yaml up -d caddy
|
||
```
|
||
|
||
### Checking logs
|
||
|
||
Follow logs for any service:
|
||
|
||
```
|
||
docker compose -f docker-compose.prod.yaml logs web --tail=100 --follow
|
||
docker compose -f docker-compose.prod.yaml logs worker --tail=100 --follow
|
||
docker compose -f docker-compose.prod.yaml logs caddy --tail=100 --follow
|
||
docker compose -f docker-compose.prod.yaml logs postgres --tail=50
|
||
```
|
||
|
||
All application logs are JSON in production (`ENV=production` activates the slog
|
||
JSON handler). Pipe through `jq` for readable output:
|
||
|
||
```
|
||
docker compose -f docker-compose.prod.yaml logs web --follow --no-log-prefix | jq .
|
||
```
|
||
|
||
### Debugging the distroless container
|
||
|
||
The runtime image (`gcr.io/distroless/static-debian12:nonroot`) has **no shell**
|
||
(RESEARCH Pitfall 7). You cannot `docker exec -it <web-container> sh`.
|
||
|
||
To debug network or filesystem issues, attach an ephemeral busybox container to the
|
||
same network:
|
||
|
||
```
|
||
# Find the web container ID:
|
||
docker compose -f docker-compose.prod.yaml ps
|
||
|
||
# Attach busybox to the web container's network namespace:
|
||
docker run --rm -it --network container:<web-container-id> busybox sh
|
||
```
|
||
|
||
From the busybox shell you can run `wget`, `nc`, `ping`, etc. to diagnose
|
||
connectivity. To inspect the compose network directly (e.g. reach `postgres:5432`):
|
||
|
||
```
|
||
docker run --rm -it \
|
||
--network $(docker inspect <web-container-id> --format '{{range .NetworkSettings.Networks}}{{.NetworkID}}{{end}}') \
|
||
busybox sh
|
||
```
|