VPS part 2: Deploy tooling and conventions
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>.newfirst. If the build fails,set -estops the script and the running binary is untouched. mvis an atomic rename on the same filesystem. There's never a moment when the binary is half-written.CGO_ENABLED=0gives a static binary with no shared C libraries to break after an OS update.-trimpathkeeps 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
activemeans the deploy failed, and the script exits non-zero. --contentwas 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. Alwaysnginx -tbefore a reload. - Static sites go in
/srv/www/vhosts/<host>/, viadeploy-static. - Go apps go in
/opt/apps/<name>/, run asgoapp@<name>, read their settings from/etc/goapps/<name>.env, listen on127.0.0.1only, and write only todata/. Deploy withdeploy-go, only frommain, 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.