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.
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.
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:
<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.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.
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:
vhosts.d/, using the shared snippets.
Always nginx -t before a reload./srv/www/vhosts/<host>/, via deploy-static./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.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.
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 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.