goonk.se / production path

Under the hood

One commit, two images, no hand-copied secrets—eventually—and hopefully no 2am surprises.

Follow a deployment ↓
01 / SOURCE

Markdown becomes a site. A push starts the build.

Astro validates the content collections, renders static HTML, and Pagefind indexes the finished output. A push to main — or a manual dispatch — starts the repository's Gitea Actions workflow.

Git repo
push →
Astro build
Pagefind index
Gitea Actions
Why this choice · Failure mode · Evidence
Why this choice
Most changes are content in Git. Static generation keeps the public runtime small, while malformed frontmatter fails the build instead of quietly breaking a production page. Git is also the content history, review trail, deployment trigger, and source for the site's generated changelog.
Failure mode
A schema, Astro, or Pagefind failure stops the image build — the currently deployed site is untouched. No successful workflow means no new artifacts; existing containers keep serving the previous version.
Sanitized evidence
npm run build
# astro build && pagefind --site dist
on:
  push:
    branches: [main]
  workflow_dispatch:
02 / BUILD

One commit becomes two images.

A self-hosted Gitea runner checks out the full history, derives image names and SHA tags, authenticates to the private registry, and starts Buildx. The workflow builds a static nginx web image and a separate Node API image, tagged as both latest and the short commit SHA. Deploy targets pull these artifacts instead of rebuilding source.

SHA
WEB
Astro / nginx
API
Node / Express
Private registry
Artifact — Web

Astro's generated HTML, CSS, JavaScript, project images, and the Pagefind search index, served by nginx.

node:24-alpine → nginx:alpine
Artifact — API

A deliberately separate Express process for Spotify, Last.fm, Immich, Gitea, and the small pieces that cannot be static.

node:24-alpine → node api/index.mjs
Why this choice · Failure mode · Evidence
Why this choice
The complete build loop stays on infrastructure I control — full history is intentional, since the changelog is generated from git log during the build. The web and API have different dependency and runtime profiles; separating them keeps the API image scoped to what it actually runs. Artifact distribution stays separate from both source checkout and runtime configuration: CI can publish while deploy hosts only need to pull.
Failure mode
A failed runner or registry login fails the workflow without affecting the live containers. Either image failing to build fails the whole workflow — the SHA tags preserve a concrete artifact identity for diagnosis and rollback. A registry outage blocks new pulls, but already-running containers don't need the registry to keep serving traffic.
Sanitized evidence
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
- uses: docker/setup-buildx-action@v3
goonk-cv:latest       goonk-cv:a1b2c3d
goonk-cv-api:latest   goonk-cv-api:a1b2c3d
tags:
  - <registry>/<project>:latest
  - <registry>/<project>:<short-sha>
03 / DEPLOY

Artifacts travel. Secrets don't.

I SSH to the target host and pull the current images; Docker Compose reconciles the two services. Runtime configuration arrives separately — keep pulls an encrypted environment bundle and decrypts it locally with a read-only deploy identity.

Private registry
goonk-web:abc123
goonk-api:abc123
keep
Encrypted vault
↓ image · ↓ encrypted config
Deploy host
↓ compose
Production
Build authority
  • Can publish images
  • Cannot read app secrets
  • Lives in Gitea Actions
Runtime authority
  • Can fetch production config
  • Cannot publish images
  • Lives on the deploy host
Why this choice · Failure mode · Evidence
Why this choice
At this scale, an explicit deploy is understandable and auditable — a registry watcher or orchestration control plane would automate a step without removing the need to understand it. A secret is pushed once, granted explicitly, rotated centrally, and audited when fetched, without the keep server ever receiving plaintext or recipient private keys.
Failure mode
A build can succeed without changing production. A failed pull leaves the currently running image available; replacement and health behavior remain visible, manual operations. An existing container keeps its current environment if keep is unavailable — a new deployment should stop rather than invent missing values.
Sanitized evidence
docker compose pull
keep pull <project>/production > .env
docker compose up -d
04 / RUNTIME

Compose runs the boring bits.

nginx serves the generated site while Express handles same-origin API routes and private integrations.

goonk.se
nginx
static Astro
Express
integrations
Spotify · Immich · Last.fm · Gitea
Why not Kubernetes?

Two containers, one persistent data volume, and one deploy host do not need a control plane. Docker Compose describes the system that actually exists, keeps recovery understandable, and leaves fewer things to break at 2am.

Why this choice · Failure mode · Evidence
Boundary
The browser sees one public origin. Spotify, Immich, Last.fm, and Gitea credentials and private upstream locations stay in the API process.
Failure mode
Static pages remain useful when an integration fails. Individual widgets are designed to degrade without taking the whole site with them.
Sanitized evidence
web  → nginx → static Astro + Pagefind
api  → Express → Spotify / Immich / Last.fm / Gitea
05 / OBSERVE

What is actually running?

node-exporter, Prometheus, Grafana, and the public status page handle operational visibility — this page only explains the system. Build identity is embedded in the artifact; deployment identity comes separately from Trace, after the deploy host has run its health checks. If either source is unavailable, this panel says so instead of guessing.

Live evidence loading evidence…
Commit
loading…
Built
loading…
Deployed
loading…
Health
unknown

Why this choice · Failure mode · Evidence
Why this choice
A portfolio page should not become a second monitoring console. Detailed metrics stay in tools designed to store, query, and alert on them.
Failure mode
Monitoring being unreachable means status is unknown — not proof that the application itself is unhealthy.
Sanitized evidence
application state ≠ monitoring state
unknown          ≠ unhealthy
Still here? Fine. Let's talk about secrets.
LEVEL 2 / TRUST BOUNDARY

What keep can — and cannot — see

The server distributes encrypted material and records access. Encryption and decryption happen at the edges, so the server never needs the plaintext, or the private key that would reveal it.

keep knows
  • Vault identifiers & encrypted payloads
  • Recipients, grants, and access timestamps
  • Which wrapped key belongs to which recipient
keep doesn't know
  • Plaintext secret values
  • Recipient private keys
  • The unwrapped symmetric payload key
keep cannot save you from
  • A compromised developer or deploy machine
  • Secrets exposed by the application itself
  • Metadata visibility — the service still has an audit trail
THE GENERAL IDEA
  1. Build once.
  2. Deploy artifacts, not source.
  3. Keep secrets out of artifacts.
  4. Give credentials only the authority they need.
  5. Let integrations fail independently.
  6. Prefer understandable recovery over clever automation.
  7. Don't run Kubernetes because the internet told you to.
keep project → Homelab case study → Site changelog →