goonk.se / production path

Under the hood

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

The real path, sanitized

Not another tech-stack list

This is how a change travels through the actual repository, build runner, private registry, deploy host, and runtime. The excerpts are deliberately small and reviewed; secrets and private network details do not belong in a portfolio diagram.

Source → production

Follow one deployment

SourceBuildArtifactsDeploySecretsRuntimeObserve
  1. Source Markdown becomes a site Astro validates the content collections, renders static HTML, and Pagefind indexes the finished output.
    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.
    Boundary
    Repository content is public site input. No runtime application secrets belong in this stage.
    When it fails
    A schema, Astro, or Pagefind failure stops the image build. The currently deployed site is untouched.
    Sanitized evidence
    npm run build
    # astro build && pagefind --site dist
    goonk.se project write-up
  2. Source A push starts the build A push to main—or a manual dispatch—starts the repository’s Gitea Actions workflow.
    Why this choice
    Git is the content history, review trail, deployment trigger, and source for the site’s generated changelog.
    Boundary
    The commit and repository history are build inputs. Nothing is deployed merely because a local file changed.
    When it fails
    No successful workflow means no new artifacts. Existing containers continue serving the previous version.
    Sanitized evidence
    on:
      push:
        branches: [main]
      workflow_dispatch:
    Git-generated changelog
  3. Build Gitea builds on owned hardware A self-hosted runner checks out the full history, derives image names and SHA tags, authenticates to the registry, and starts Buildx.
    Why this choice
    The complete build loop stays on infrastructure I control. Full history is intentional: the changelog is generated from git log during the build.
    Boundary
    CI has the credential needed to publish images. That credential is not an application runtime secret.
    When it fails
    A failed runner or registry login fails the workflow without affecting the live containers.
    Sanitized evidence
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0
    - uses: docker/setup-buildx-action@v3
    Homelab case study
  4. Artifacts One commit, two images The workflow builds a static nginx web image and a separate Node API image, tagged as both latest and the short commit SHA.
    Why this choice
    The web and API have different dependency and runtime profiles. Separating them keeps the API image scoped to what it actually runs.
    Boundary
    Images contain code and public build output—not the production `.env`. Runtime configuration is supplied later.
    When it fails
    Either failed build fails the workflow. The SHA tags preserve a concrete artifact identity for diagnosis and rollback.
    Sanitized evidence
    goonk-cv:latest       goonk-cv:a1b2c3d
    goonk-cv-api:latest   goonk-cv-api:a1b2c3d
    Site architecture
  5. Artifacts The registry distributes artifacts The private Gitea registry stores the versioned images; deploy targets pull the artifacts instead of rebuilding source.
    Why this choice
    Artifact distribution stays separate from both source checkout and runtime configuration. CI can publish while deploy hosts only need to pull.
    Boundary
    Registry access controls images, not application credentials. Public diagrams intentionally omit registry tokens and internal details.
    When it fails
    An outage blocks new pulls, but already-running containers do not need the registry to keep serving traffic.
    Sanitized evidence
    tags:
      - <registry>/<project>:latest
      - <registry>/<project>:<short-sha>
    CI/CD trade-offs
  6. Deploy Deployment stays explicit I SSH to the target host, pull the current images, and let Docker Compose reconcile the two services.
    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.
    Boundary
    The target needs read access to images and access to its local runtime configuration. It does not need CI’s publishing credential.
    When it fails
    A build can succeed without changing production. A failed pull leaves the currently running image available; replacement and health behavior remain visible, manual operations.
    Sanitized evidence
    docker compose pull
    docker compose up -d
    Current homelab topology
  7. Secrets keep replaces copied secrets The proven deployment pattern pulls an encrypted environment bundle and decrypts it locally with a read-only deploy identity.
    Why this choice
    A secret is pushed once, granted explicitly, rotated centrally, and audited when fetched—without the keep server receiving plaintext or recipient private keys.
    Boundary
    The goonk deploy identity has read-only access to its production vault: it can fetch configuration, but cannot push a revision, rotate it, or grant another recipient access.
    When it fails
    An existing container keeps its current environment if keep is unavailable. A new deployment should stop rather than invent missing values.
    Sanitized evidence
    keep pull <project>/production > .env
    docker compose up -d
    How keep actually works
  8. Runtime Compose runs the boring bits nginx serves the generated site while Express handles same-origin API routes and private integrations.
    Why this choice
    Two services and one persistent API data volume do not justify Kubernetes. Compose describes the actual runtime with fewer moving parts.
    Boundary
    The browser sees one public origin. Spotify, Immich, Last.fm, and Gitea credentials and private upstream locations stay in the API process.
    When it fails
    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
    Site project page
  9. Observe Dedicated tools watch production node-exporter, Prometheus, Grafana, and the public status page handle operational visibility; this page only explains the system.
    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.
    Boundary
    No raw logs, private labels, internal addresses, or metrics endpoints are published here.
    When it fails
    Monitoring being unreachable means status is unknown—not proof that the application itself is unhealthy.
    Sanitized evidence
    application state ≠ monitoring state
    unknown          ≠ unhealthy
    Public status page

Each stage expands. The full explanation remains available without JavaScript.

The interesting boundary

What keep can—and cannot—see

IN USE

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.

The server does see

  • Vault identifiers and encrypted payloads
  • Recipients, grants, and access timestamps
  • Which wrapped key belongs to which recipient

The server does not see

  • Plaintext secret values
  • Recipient private keys
  • The unwrapped symmetric payload key

It does not magically solve

  • A compromised developer or deploy machine
  • Secrets exposed by the application itself
  • Metadata privacy—the service still has an audit trail
Read the complete keep case study
Same pipeline, different authority

Build credentials are not runtime secrets

ConcernBuild / CIDeploy / runtime
PurposeProduce and publish immutable imagesConfigure one environment
ExampleRegistry publishing credentialSpotify, Immich, and API credentials
StorageGitea Actions secret where requiredEncrypted keep vault; decrypted by the deploy identity
Inside image?No. Application secrets are not build artifacts.
RotationChange publishing accessPush a new vault revision and redeploy
Artifact 1

Web

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

node:24-alpine → nginx:alpine
Artifact 2

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
Deliberately boring

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.

Under the Hood V2

What is actually running?

loading evidence…

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.

Commit
loading…
Built
loading…
Deployed
loading…
Health
unknown

The boundary remains

Static first. Live only when it is truthful.

The explanation works without this receipt. Live metadata is an enhancement with explicit provenance, bounded fields, and an honest unavailable state.