Case Study

vps_baseline

Configuration as code for the single VPS that hosts and deploys my products: edge proxy, self-hosted GitLab and CI runner, with encrypted secrets.

Period
– present
Role
Sole engineer and operator
  • 3 (davblu.dev, Jutsu Vault, Nora)

    Applications deployed by its CI

  • 7

    Architecture decision records

  • 36

    Commits

  • 1 day (20 September 2026)

    First commit to GitLab and CI running

One VPS, rebuilt from a repository, that deploys davblu.dev, Jutsu Vault and Nora on every push to main

Problem

My products needed somewhere to run and a way to ship that did not depend on hosted CI or a platform bill per app. A single VPS can do it, but only if its setup is written down and reproducible: every service, every secret and every reason for the arrangement, so the host can be rebuilt from the repository and a new app can be added without touching the host by hand.

What was built

vps_baseline defines the long-lived services on the host: Traefik as the edge proxy with one Let's Encrypt wildcard certificate for *.davblu.dev issued over Cloudflare DNS-01, GitLab CE at git.davblu.dev, and one GitLab Runner that deploys to the same Docker daemon. Secrets are committed encrypted with SOPS and age, one file per service, readable by two independent keys so losing either machine loses nothing. Host configuration such as SSH hardening and a daily Docker prune that alerts on Telegram below 50 GB free lives in the same repository, at the path it occupies on the host.

The hard part was the seams. One document is the whole contract an application repository must meet to be deployed here: join the edge network and declare its routing labels, and the wildcard DNS record and certificate make its hostname work over HTTPS with no extra step. Every trade-off is written down as an ADR, including the uncomfortable ones: no host firewall, a runner with the host Docker socket, and GitLab exposed without the Cloudflare proxy. An agent skill, deploy-vps, lets AI coding agents take an application from its repository to a verified HTTPS deployment on this host.

Outcome

The host runs Traefik, GitLab and the runner today, and its CI deploys davblu.dev, Jutsu Vault and Nora, each from its own repository. Changes to the Baseline itself are applied by hand over SSH with a short documented procedure that is a no-op when nothing changed. An outbound-only Mailer is decided and recorded (ADR 0007) but not yet built. The repository is private; the products it hosts are the evidence.

Shipping Timeline

From zero to production.

  1. 01 · Architecture

    Domain model and ADRs written first

    Baseline versus Deployed applications, grey cloud, wildcard TLS, no registry, SOPS.

  2. 02 · Build

    SSH hardening and encrypted secrets

    Key-only SSH; SOPS with two age recipients.

  3. 03 · Deploy

    Traefik, GitLab CE and the runner up

    Wildcard certificate over DNS-01, GitLab with SSH on port 30022, one runner on the host daemon.

  4. 04 · Build

    The contract for application repositories

    One document is the whole interface between this host and the apps it deploys.

  5. 05 · Support

    GitLab critical patch 19.3.3 applied

  6. 06 · Support

    Daily Docker pruning with a free-space alert

    Telegram message whenever free space on / drops below 50 GB.

  7. 07 · Build

    deploy-vps agent skill

    AI agents deploy an application through GitLab CI and verify HTTPS.

Engineering

How it is built.

Stack

  • Docker
  • GitLab CI/CD
  • Traefik
  • Linux
  • Encrypted secrets (SOPS, dotenvx)
  • TLS automation (ACME)
  • Agent skills (Claude Code)

Decisions

Secrets committed, encrypted with SOPS and age
An unencrypted .env on the host would leave the host as the only copy of the configuration. Encrypted files make it reproducible and reviewable, two age keys on two machines survive the loss of either without anyone copying key material, and one file per service keeps the Cloudflare token out of GitLab's environment.
GitLab is grey-clouded, the rest of the zone is not
Cloudflare's free plan caps proxied request bodies at 100 MB and cannot proxy SSH, so a proxied GitLab would cap every Git LFS object at 100 MB and need a second hostname for SSH. GitLab resolves straight to the host, giving up DDoS shielding on a private instance to avoid an object storage tier; applications stay behind the orange-clouded wildcard.
No host firewall, on purpose
Docker writes its own iptables rules ahead of UFW, so a UFW ruleset would claim ports are closed while published container ports stay open. Exposure is controlled by which ports are published, SSH accepts keys only, and publishing a port is treated as a security decision.
The runner mounts the host Docker socket
CI has to start application containers on this host's edge network; docker-in-docker cannot, and a shell executor has no isolation. Socket access is root-equivalent, which is acceptable only because this is a single-user private instance; untrusted contributors or shared pipelines would reopen the decision.
Mail goes out directly, trusted by network
Planned outbound mail will go straight to recipients' servers with SPF, DKIM and DMARC per sender domain, avoiding a third-party relay. Senders are admitted by joining the Mail network rather than by credentials, since every sender is one of my own containers, and a relay remains the fallback if deliverability suffers.