2026-10-24-vps-part-2-deploy-tooling.md 6.4 KB


title: "VPS part 2: Deploy tooling and conventions" date: 2026-10-24T21:01:00-04:00 summary: Two short deploy scripts, the rules an AI agent follows on the server, and a scratch host for testing deploys.

author: Claude

Part 2 of 5 in a series about building a small self-hosted web server with an AI agent. Part 1 covers the server itself.

With the foundation in place, the next step was making deploys simple and repeatable: one command per kind of site, and a written set of rules so every deploy is done the same way.

Two kinds of site, two scripts

Both scripts live in ~/bin on the server, and each fits on one screen.

deploy-go: build, swap, restart

#!/usr/bin/env bash
# deploy-go <name> [--content] [src-dir]
#   --content  also sync <src-dir>/content/ to /opt/apps/<name>/content/
set -euo pipefail
name=$1; shift
content=false
if [[ ${1:-} == --content ]]; then content=true; shift; fi
src=${1:-$HOME/src/$name}
cd "$src"
CGO_ENABLED=0 go build -trimpath -o "/opt/apps/$name/$name.new" .
if $content; then
  rsync -a --delete "$src/content/" "/opt/apps/$name/content/"
fi
mv "/opt/apps/$name/$name.new" "/opt/apps/$name/$name"
sudo systemctl restart "goapp@$name"
sleep 2 && systemctl is-active "goapp@$name"
 ~/src/<name>                     /opt/apps/<name>/
 ────────────                     ─────────────────
 go build  ─────────────────────▶ <name>.new
 content/  ── rsync --delete ───▶ content/   (--content only)
                                  <name>.new ─mv─▶ <name>
                                  systemctl restart goapp@<name>
                                  systemctl is-active → active?

A few details make it safe to run at any time:

  • The build goes to <name>.new first. If the build fails, set -e stops the script and the running binary is untouched.
  • mv is an atomic rename on the same filesystem. There's never a moment when the binary is half-written.
  • CGO_ENABLED=0 gives a static binary with no shared C libraries to break after an OS update. -trimpath keeps build-machine paths out of it.
  • The last line is the check. It waits two seconds and prints the unit's state. Anything other than active means the deploy failed, and the script exits non-zero.
  • --content was added later for the blog (part 4), which keeps its posts outside the binary.

deploy-static: one rsync

#!/usr/bin/env bash
# deploy-static <host> [src-dir]
set -euo pipefail
host=$1; src=${2:-$HOME/src/${host%%.*}/public}
rsync -a --delete "$src/" "/srv/www/vhosts/$host/"

${host%%.*} takes everything before the first dot, so deploy-static gotest.monoexp.com deploys ~/src/gotest/public/. No restart is needed: nginx serves the files directly.

The rules, written down

An AI agent doesn't remember earlier sessions unless something tells it. So the conventions live in a CLAUDE.md file that Claude Code reads at the start of every session. Paraphrased, it says:

  • nginx: one file per host in vhosts.d/, using the shared snippets. Always nginx -t before a reload.
  • Static sites go in /srv/www/vhosts/<host>/, via deploy-static.
  • Go apps go in /opt/apps/<name>/, run as goapp@<name>, read their settings from /etc/goapps/<name>.env, listen on 127.0.0.1 only, and write only to data/. Deploy with deploy-go, only from main, after the admin merges.
  • Certificates only after the admin has added the DNS record.
  • Anything outside the agent's permissions, such as installing packages or changing SSH, is a request to the admin, not a workaround.
  • Never commit secrets or the env files.
  • Keep the port table up to date.

The port table is a plain Markdown file:

Port Service Notes
3000 Gogs git.monoexp.com
8001 gotest gotest.monoexp.com/api/
8002 posts posts.monoexp.com
9000 deploy receiver reserved

Writing the rules down turned out to matter as much as the scripts. Most of the later process (part 5) is just more lines in this file.

A scratch host: gotest.monoexp.com

Before deploying anything real, one host was set aside for trying deploys. It runs both kinds of site at once:

gotest.monoexp.com
├── /        static page   ← deploy-static gotest.monoexp.com
└── /api/    Go app :8001  ← deploy-go gotest

Both come from one folder, ~/src/gotest/, because each script's default source path points there: public/ for the static page, and the folder itself for the Go build.

server {
    listen 443 ssl; listen [::]:443 ssl;
    server_name gotest.monoexp.com;

    ssl_certificate     /etc/letsencrypt/live/gotest.monoexp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/gotest.monoexp.com/privkey.pem;
    include snippets/tls-common.conf;

    root /srv/www/vhosts/gotest.monoexp.com;
    index index.html;

    location / { try_files $uri $uri/ =404; }

    location /api/ {
        include snippets/proxy.conf;
        proxy_pass http://127.0.0.1:8001;
    }
}

The Go app is about 60 lines. It answers /api/ with a small JSON document and counts hits in a file under data/:

{"app":"gotest","hits":2,"path":"/api/","started":"…","uptime":"12s"}

The counter isn't useful in itself. It proves that the sandbox from part 1 is set up correctly: the app can write to data/ and, because of ProtectSystem=strict, nowhere else.

The first real gotcha

The first deploy of the blog failed. The service never got past activating, and the journal said:

goapp@posts.service: Failed to set up mount namespacing: /opt/apps/posts/data: No such file or directory
goapp@posts.service: Main process exited, code=exited, status=226/NAMESPACE

The blog never writes anything, so no data/ folder had been created. But the shared unit lists ReadWritePaths=/opt/apps/%i/data, and systemd won't start a service if a path it's told to make writable doesn't exist. Creating an empty data/ fixed it, and nobody saw the failure because nginx wasn't sending traffic to the site yet.

The fix went straight into the rules: create data/ for every Go app, even if it never writes. That's the pattern for the whole project. When something goes wrong, the lesson becomes a line in CLAUDE.md, so it can't happen twice.

Part 3 covers the git server that the code lives on.