# Phase 7: Deploy v1 - Pattern Map

**Mapped:** 2026-05-15
**Files analyzed:** 8
**Analogs found:** 6 / 8

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `backend/Dockerfile` | config | batch (build pipeline) | `apps/api/Dockerfile` (TS monorepo) | partial — different language, same multi-stage concept |
| `backend/docker-compose.prod.yaml` | config | request-response | `backend/compose.yaml` | exact — same compose v2 syntax, same postgres service |
| `backend/deploy/Caddyfile` | config | request-response | none in codebase | no analog |
| `backend/cmd/web/main.go` (modify) | config | request-response | `backend/cmd/worker/main.go` | exact — rivermigrate startup is the model for goose.Up() |
| `backend/internal/web/handlers.go` (modify) | handler | request-response | `backend/internal/web/handlers.go` itself | self — refactor existing HealthzHandler, add ReadyzHandler |
| `backend/internal/web/handlers_test.go` (modify) | test | request-response | `backend/internal/web/handlers_test.go` itself | self — extend existing TestHealthz_* tests, add TestReadyz_* |
| `backend/internal/web/router.go` (modify) | config | request-response | `backend/internal/web/router.go` itself | self — add /readyz route, update /healthz, change staticDir to fs.FS |
| `backend/assets/assets.go` (new) | utility | batch (embed) | none in codebase | no analog — first go:embed usage in this codebase |
| `backend/internal/db/migrate.go` (new) | utility | batch (migration) | `backend/cmd/worker/main.go` lines 54–68 | role-match — rivermigrate startup pattern |
| `backend/README.md` (modify) | config | — | `backend/README.md` itself | self — extend existing structure |
| `backend/.env.example` (modify) | config | — | `backend/.env.example` itself | self — add R2/prod vars |

## Pattern Assignments

---

### `backend/docker-compose.prod.yaml` (config, request-response)

**Analog:** `backend/compose.yaml`

**Postgres service pattern** (compose.yaml lines 1–18):
```yaml
services:
  postgres:
    image: postgres:16-alpine
    container_name: xtablo-backend-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: xtablo
      POSTGRES_USER: xtablo
      POSTGRES_PASSWORD: xtablo
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"]
      interval: 5s
      timeout: 5s
      retries: 10
```

**Key differences in prod compose vs dev compose:**
- No `ports:` on postgres (internal network only — Pitfall 5 in RESEARCH)
- No minio / minio-init services (replaced by R2 env vars)
- Add `web` service: `image: ${IMAGE}:${TAG}`, `command: /app/web`, `env_file: .env.prod`, `depends_on: postgres: condition: service_healthy`, `expose: ["8080"]`
- Add `worker` service: same image, `command: /app/worker`, same env_file and depends_on
- Add `caddy` service: `image: caddy:2-alpine`, `ports: ["80:80","443:443","443:443/udp"]`, volumes for Caddyfile bind-mount + named caddy_data + caddy_config
- Volumes block: `postgres_data`, `caddy_data`, `caddy_config` (minio_data removed)
- Healthcheck interval: change to `interval: 10s`, `retries: 10` (slower startup tolerance in prod vs dev's 5s)

---

### `backend/cmd/web/main.go` — add goose.Up() (modify, batch/startup)

**Analog:** `backend/cmd/worker/main.go` lines 54–68 — rivermigrate startup pattern

**rivermigrate pattern to mirror** (cmd/worker/main.go lines 54–68):
```go
// Step 2: Run river migrations before constructing the client (D-02).
// rivermigrate is idempotent — already-applied versions are skipped.
migrator, err := rivermigrate.New(riverpgxv5.New(pool), nil)
if err != nil {
    slog.Error("rivermigrate init failed", "err", err)
    os.Exit(1)
}
res, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil)
if err != nil {
    slog.Error("river migration failed", "err", err)
    os.Exit(1)
}
for _, v := range res.Versions {
    slog.Info("river migration applied", "version", v.Version)
}
```

**goose.Up() equivalent to insert after pool creation** (after cmd/web/main.go line 64 `pool, err := db.NewPool`):
```go
// Step N: Run goose migrations at startup (D-10). db.RunMigrations is
// idempotent — already-applied versions are skipped via goose_db_version table.
if err := db.RunMigrations(ctx, pool); err != nil {
    slog.Error("migrations failed", "err", err)
    os.Exit(1)
}
```

**Ordering constraint:** Insert the goose.Up() call AFTER `pool, err := db.NewPool(ctx, dsn)` (line 64) and BEFORE `q := sqlc.New(pool)` (line 71). Matches the worker's pattern of running migrations before constructing any higher-level clients.

**staticDir → fs.FS change** (cmd/web/main.go line 116):
```go
// BEFORE:
router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)

// AFTER (once assets package exists):
import "backend/assets"
router := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
```

---

### `backend/internal/db/migrate.go` (new, utility/batch)

**Analog:** `backend/cmd/worker/main.go` — rivermigrate pattern (role-match: different library, same idiom of programmatic migration before server start)

**Full file pattern** (new file, no existing analog — use RESEARCH Pattern 2 exactly):
```go
package db

import (
    "context"
    "database/sql"

    "github.com/jackc/pgx/v5/pgxpool"
    _ "github.com/jackc/pgx/v5/stdlib"  // registers "pgx/v5" driver with database/sql
    "github.com/pressly/goose/v3"
)

// RunMigrations opens a *sql.DB from the pool's connection string and runs all
// pending goose migrations embedded via the assets package. Idempotent:
// already-applied versions are skipped via the goose_db_version table.
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error {
    dsn := pool.Config().ConnConfig.ConnString()
    db, err := sql.Open("pgx/v5", dsn)
    if err != nil {
        return err
    }
    defer db.Close()

    goose.SetBaseFS(migrationsFS)
    if err := goose.SetDialect("postgres"); err != nil {
        return err
    }
    return goose.Up(db, "migrations")
}
```

**Embed path note (RESEARCH Open Question 1):** The `//go:embed migrations` directive cannot live in `backend/internal/db/migrate.go` because `backend/migrations/` is 2 levels up and `go:embed` cannot use `..`. The embed directive MUST live in `backend/assets/assets.go` (or `cmd/web/main.go`). Pass the `embed.FS` into `RunMigrations` as a parameter, or expose it via the assets package.

**Preferred signature** (accepts embed.FS as parameter so the function itself does not carry an embed directive):
```go
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error
```

Called from `cmd/web/main.go` as:
```go
db.RunMigrations(ctx, pool, assets.Migrations)
```

---

### `backend/assets/assets.go` (new, utility/embed)

**Analog:** None in this codebase — first `go:embed` usage. Pattern sourced from RESEARCH Pattern 1.

**Full file pattern:**
```go
// Package assets embeds the static web assets and SQL migration files into the
// binary at build time (D-09, D-10). No runtime file dependencies.
package assets

import "embed"

// Static holds the compiled CSS, HTMX JS, and Sortable.js served at /static/*.
// The embed directive must be relative to this file; backend/static/ is a sibling
// of backend/assets/ at the module root level.
//go:embed static
var Static embed.FS

// Migrations holds the goose SQL migration files embedded for programmatic
// execution at startup (D-10).
//go:embed migrations
var Migrations embed.FS
```

**Path verification required:** This file sits at `backend/assets/assets.go`. The `//go:embed static` path resolves to `backend/assets/static` — but `static/` is at `backend/static/`. The directory must either be moved inside `backend/assets/`, symlinked, or the embed directive must be placed in a file at `backend/` itself (e.g., `backend/embed.go`). See RESEARCH Pitfall 1 and Open Question 1.

**Alternative (embed.go at module root):**
```go
// backend/embed.go
package main  // or a dedicated package

//go:embed static
var Static embed.FS

//go:embed migrations
var Migrations embed.FS
```

This works because `backend/embed.go` is a sibling of `backend/static/` and `backend/migrations/`. The planner should prefer this approach over an `assets/` package if the path constraint cannot be resolved cleanly.

---

### `backend/internal/web/handlers.go` (modify, handler/request-response)

**Analog:** `backend/internal/web/handlers.go` itself — refactor existing HealthzHandler

**Existing HealthzHandler pattern** (handlers.go lines 16–35) — this becomes ReadyzHandler:
```go
func HealthzHandler(pinger Pinger) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
        defer cancel()
        w.Header().Set("Content-Type", "application/json")
        if err := pinger.Ping(ctx); err != nil {
            w.WriteHeader(http.StatusServiceUnavailable)
            _ = json.NewEncoder(w).Encode(map[string]string{
                "status": "degraded",
                "db":     "down",
            })
            return
        }
        w.WriteHeader(http.StatusOK)
        _ = json.NewEncoder(w).Encode(map[string]string{
            "status": "ok",
            "db":     "ok",
        })
    }
}
```

**Required changes:**
1. Rename existing `HealthzHandler(pinger Pinger)` → `ReadyzHandler(pinger Pinger)` (keep all logic identical)
2. Add new `HealthzHandler()` with no pinger dependency:
```go
// HealthzHandler is a pure liveness check (D-12). Returns 200 immediately
// as long as the server process is up. No DB ping.
func HealthzHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte(`{"status":"ok"}`))
    }
}
```

**Imports:** Remove `"context"` and `"time"` from `HealthzHandler` imports (they move to `ReadyzHandler`). Both handlers remain in `handlers.go`.

---

### `backend/internal/web/router.go` (modify, config/request-response)

**Analog:** `backend/internal/web/router.go` itself

**Existing static file serving pattern** (router.go line 120–121):
```go
fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))
r.Get("/static/*", fs.ServeHTTP)
```

**Replacement pattern using embed.FS** (RESEARCH Pattern 1 / Code Examples):
```go
import "io/fs"

// In NewRouter signature: change `staticDir string` → `staticFS fs.FS`
sub, err := fs.Sub(staticFS, "static")
if err != nil {
    panic("static embed sub failed: " + err.Error())
}
fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
r.Get("/static/*", fileServer.ServeHTTP)
```

**Signature change** (router.go line 47):
```go
// BEFORE:
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, ...) http.Handler

// AFTER:
func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, ...) http.Handler
```

**Route changes** (router.go line 117):
```go
// BEFORE:
r.Get("/healthz", HealthzHandler(pinger))

// AFTER:
r.Get("/healthz", HealthzHandler())          // D-12: liveness, no pinger
r.Get("/readyz", ReadyzHandler(pinger))       // D-13: readiness, DB ping
```

**Test compatibility note:** All existing tests that call `NewRouter(stubPinger{}, "./static", ...)` must change their second argument. Tests can pass `os.DirFS("./static")` to avoid embedding during unit test runs.

---

### `backend/internal/web/handlers_test.go` (modify, test/request-response)

**Analog:** `backend/internal/web/handlers_test.go` itself

**Existing tests to update** (handlers_test.go lines 24–61):

`TestHealthz_OK` (lines 24–43) — change to test the new no-pinger liveness handler:
```go
func TestHealthz_OK(t *testing.T) {
    rec := httptest.NewRecorder()
    req := httptest.NewRequest(http.MethodGet, "/healthz", nil)

    // BEFORE: HealthzHandler(stubPinger{err: nil})
    // AFTER: HealthzHandler() — no pinger dependency
    HealthzHandler().ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d; want 200", rec.Code)
    }
    // Remove db:ok assertion — new liveness handler only returns {"status":"ok"}
    if !strings.Contains(rec.Body.String(), `"status":"ok"`) {
        t.Errorf("body missing status:ok; got: %s", rec.Body.String())
    }
}
```

`TestHealthz_Down` (lines 45–61) — delete this test (the new HealthzHandler has no failure mode).

**New tests to add** (mirror old TestHealthz structure, now for ReadyzHandler):
```go
func TestReadyz_OK(t *testing.T) {
    rec := httptest.NewRecorder()
    req := httptest.NewRequest(http.MethodGet, "/readyz", nil)

    ReadyzHandler(stubPinger{err: nil}).ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d; want 200", rec.Code)
    }
    body := rec.Body.String()
    if !strings.Contains(body, `"status":"ok"`) {
        t.Errorf("body missing status:ok; got: %s", body)
    }
    if !strings.Contains(body, `"db":"ok"`) {
        t.Errorf("body missing db:ok; got: %s", body)
    }
}

func TestReadyz_Down(t *testing.T) {
    rec := httptest.NewRecorder()
    req := httptest.NewRequest(http.MethodGet, "/readyz", nil)

    ReadyzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req)

    if rec.Code != http.StatusServiceUnavailable {
        t.Fatalf("status = %d; want 503", rec.Code)
    }
    body := rec.Body.String()
    if !strings.Contains(body, `"status":"degraded"`) {
        t.Errorf("body missing status:degraded; got: %s", body)
    }
    if !strings.Contains(body, `"db":"down"`) {
        t.Errorf("body missing db:down; got: %s", body)
    }
}
```

**stubPinger reuse:** The existing `stubPinger` struct (handlers_test.go lines 18–22) is unchanged — it satisfies `Pinger` and is used by the new ReadyzHandler tests identically.

**TestIndex_UnauthRedirects and other router-level tests** (handlers_test.go lines 68–121) — all pass `"./static"` as second arg to `NewRouter`. Change to `os.DirFS("./static")` after the signature changes. Requires adding `"os"` to imports.

---

### `backend/Dockerfile` (new, config/batch)

**Analog:** No Go Dockerfile in this codebase. Pattern sourced from RESEARCH Pattern 3.

**Key decisions from justfile** (justfile lines 20–81 — pinned versions to match):
- `templ_version := "v0.3.1020"` — use this exact version in `go install github.com/a-h/templ/cmd/templ@v0.3.1020`
- `tailwind_version := "v4.0.0"` — Tailwind standalone CLI download
- `htmx_version := "2"` — unpkg.com HTMX download
- `sortable_version := "1.15.7"` — CDN Sortable.js download
- Builder base: `golang:1.26-alpine` (matches go.mod `go 1.26.1`)
- Runtime base: `gcr.io/distroless/static-debian12:nonroot`

**Asset generation step** (mirrors justfile `bootstrap` + `generate` recipes):
```dockerfile
# Stage 1: Generate static assets (CSS + JS)
FROM node:20-alpine AS assets
WORKDIR /build
RUN apk add --no-cache curl
# Download Tailwind standalone CLI (pinned version from justfile)
RUN curl -sSL -o /usr/local/bin/tailwindcss \
    "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.0.0/tailwindcss-linux-x64" \
    && chmod +x /usr/local/bin/tailwindcss
# Download HTMX and Sortable.js (versions pinned in justfile)
RUN curl -sSL -o static/htmx.min.js "https://unpkg.com/htmx.org@2/dist/htmx.min.js" \
    && curl -sSL -o static/sortable.min.js \
       "https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js"
COPY tailwind.input.css .
COPY templates/ templates/
RUN tailwindcss -i tailwind.input.css -o static/tailwind.css --minify
```

**Go build stage** (CGO_ENABLED=0 is mandatory for distroless/static — RESEARCH Pitfall 7):
```dockerfile
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=assets /build/static ./static

# templ generate BEFORE go build — *_templ.go files are gitignored (RESEARCH Pitfall 6)
RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate

RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux \
    go build -ldflags="-s -w" -trimpath -o /app/web ./cmd/web

RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux \
    go build -ldflags="-s -w" -trimpath -o /app/worker ./cmd/worker
```

**Runtime stage** (D-08: no CMD — compose overrides with `command: /app/web` or `/app/worker`):
```dockerfile
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/web /app/web
COPY --from=builder /app/worker /app/worker
EXPOSE 8080
# No CMD — docker-compose.prod.yaml sets `command:` per service (D-08)
```

---

### `backend/deploy/Caddyfile` (new, config/request-response)

**Analog:** None in codebase.

**Pattern** (RESEARCH Pattern 6 — simple single-site reverse proxy):
```caddyfile
{$DOMAIN} {
    reverse_proxy web:8080
}
```

`{$DOMAIN}` is Caddy's env var interpolation syntax. The host `.env.prod` must set `DOMAIN=app.yourdomain.com`. Caddy automatically handles HTTP→HTTPS redirect and Let's Encrypt certificate issuance when a domain (not IP) is specified.

**Persistent volume requirement:** The caddy service in docker-compose.prod.yaml MUST mount named volumes at `/data` and `/config` — without them, certificates are re-issued on every restart and hit Let's Encrypt rate limits (RESEARCH Pitfall 4).

---

### `backend/.env.example` (modify, config)

**Analog:** `backend/.env.example` itself

**Existing vars** (current .env.example):
```bash
DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
TEST_DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
SESSION_SECRET=
PORT=8080
ENV=development
```

**Add these vars** (R2 / production vars per D-05, D-06):
```bash
# S3-compatible object storage (Cloudflare R2 in prod; MinIO in local dev via compose.yaml)
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=xtablo-dev
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_USE_PATH_STYLE=true   # true for MinIO; false for R2

# Maximum upload size in megabytes (default: 25)
MAX_UPLOAD_SIZE_MB=25

# Production domain for Caddy TLS (only needed in docker-compose.prod.yaml context)
# DOMAIN=app.yourdomain.com
```

**Note on TEST_DATABASE_URL:** The comment in CONTEXT.md and RESEARCH.md says to remove the `TEST_DATABASE_URL` note. Keep the var but update the comment to clarify it's dev/test-only and should not be set in the prod `.env.prod` file.

---

### `backend/README.md` (modify, documentation)

**Analog:** `backend/README.md` itself (lines 1–240 exist; add sections after line 240)

**Sections to add** (append to existing README after the "What Phase 1 ships" section):

The existing README structure uses H2 headers (`##`) for top-level sections. New sections follow the same pattern:

```markdown
## Deploy

Production runs on a Hetzner VM via plain Docker Compose. No PaaS, no Kubernetes (D-01, D-02).

### First-time setup
[SSH, docker install, image build, .env.prod creation, docker compose up steps]

### Deploying a new version
[docker build, tag, compose pull/up steps]

## Rollback

Normal rollback = change image tag in .env.prod + `docker compose up -d` (D-11).
[exact commands]

## Incident runbook

### /readyz returns 503
### Database unreachable
### Caddy TLS certificate errors
[break-glass steps including goose down documentation]
```

---

## Shared Patterns

### slog structured logging
**Source:** `backend/cmd/web/main.go` lines 41–41 and `backend/internal/web/slog.go`
**Apply to:** All new Go files that log at startup or on error
```go
slog.SetDefault(slog.New(web.NewSlogHandler(env, os.Stdout)))
// Then use slog.Error / slog.Info / slog.Warn directly
slog.Error("migrations failed", "err", err)
os.Exit(1)
```

### os.Exit(1) on startup failure
**Source:** `backend/cmd/web/main.go` lines 43–47, `backend/cmd/worker/main.go` lines 33–36
**Apply to:** `db.RunMigrations` call in `cmd/web/main.go`
```go
if err := someStartupStep(...); err != nil {
    slog.Error("step failed", "err", err)
    os.Exit(1)
}
```

### httptest handler testing
**Source:** `backend/internal/web/handlers_test.go` lines 24–43
**Apply to:** TestReadyz_OK, TestReadyz_Down, updated TestHealthz_OK
```go
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/route", nil)
MyHandler(args...).ServeHTTP(rec, req)
if rec.Code != http.StatusOK { t.Fatalf(...) }
```

### pgxpool DSN extraction for sql.DB bridge
**Source:** RESEARCH Pattern 2 (no existing codebase usage — first occurrence)
**Apply to:** `backend/internal/db/migrate.go`
```go
dsn := pool.Config().ConnConfig.ConnString()
db, err := sql.Open("pgx/v5", dsn)
// Import _ "github.com/jackc/pgx/v5/stdlib" to register the driver
```

---

## No Analog Found

Files with no close match in the codebase (planner should use RESEARCH.md patterns instead):

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `backend/deploy/Caddyfile` | config | request-response | No reverse proxy config exists in codebase; use RESEARCH Pattern 6 exactly |
| `backend/assets/assets.go` | utility | embed | No `go:embed` usage exists anywhere in this codebase; use RESEARCH Pattern 1; verify embed path resolution before committing |
| `backend/Dockerfile` | config | batch | No Go Dockerfile in this repo; TS `apps/api/Dockerfile` exists but is Node-based and not a useful pattern source; use RESEARCH Pattern 3 |

---

## Critical Implementation Warnings

1. **go:embed path constraint (RESEARCH Pitfall 1):** The `//go:embed` path must be relative to the .go file and cannot use `..`. Since `static/` and `migrations/` are at `backend/`, the embed directive file must also be at `backend/` — either as `backend/embed.go` (a file in `package main` or a new package) or inside `backend/assets/` if the directories are moved there. Verify before implementing.

2. **templ generate in Docker (RESEARCH Pitfall 6):** `*_templ.go` files are gitignored. The Dockerfile builder stage MUST run `templ generate` at the pinned version (`v0.3.1020` from justfile) before `go build`, or the build will fail with undefined template functions.

3. **goose needs *sql.DB not *pgxpool.Pool (RESEARCH Pitfall 2):** Must import `_ "github.com/jackc/pgx/v5/stdlib"` and bridge via `sql.Open("pgx/v5", connStr)`. Close the `*sql.DB` with `defer db.Close()` after migrations — the pool remains open for app use.

4. **Postgres ports in prod compose:** Do NOT add `ports:` to the postgres service in `docker-compose.prod.yaml`. The postgres service is internal-only. The dev `compose.yaml` exposes `5432:5432` for local tooling — this must not be replicated in prod.

5. **caddy_data volume:** Must be a named volume in `docker-compose.prod.yaml`. Without it, Caddy re-issues certificates on every restart and will hit Let's Encrypt rate limits (RESEARCH Pitfall 4).

6. **web service depends_on with health condition:** `depends_on: postgres: condition: service_healthy` is required so `goose.Up()` does not race Postgres startup. The dev `compose.yaml` postgres service already has a `healthcheck:` — mirror it in the prod compose.

## Metadata

**Analog search scope:** `backend/cmd/`, `backend/internal/`, `backend/compose.yaml`, `backend/.env.example`, `backend/README.md`, `backend/justfile`
**Files scanned:** 14
**Pattern extraction date:** 2026-05-15
