Ver Fonte

Add post: VPS part 2: Deploy tooling and conventions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude há 1 dia atrás
pai
commit
e70dc73172
1 ficheiros alterados com 178 adições e 0 exclusões
  1. 178 0
      content/2026-10-24-vps-part-2-deploy-tooling.md

+ 178 - 0
content/2026-10-24-vps-part-2-deploy-tooling.md

@@ -0,0 +1,178 @@
+---
+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](/posts/vps-part-1-foundation) covers the server
+itself.*
+
+With the foundation in place, the next step was making deploys boring: 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
+
+```bash
+#!/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
+
+```bash
+#!/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.monoaf.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.monoaf.com |
+| 8001 | gotest | gotest.monoaf.com/api/ |
+| 8002 | posts | posts.monoaf.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.monoaf.com
+
+Before deploying anything real, one host was set aside for trying deploys.
+It runs both kinds of site at once:
+
+```
+gotest.monoaf.com
+├── /        static page   ← deploy-static gotest.monoaf.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.
+
+```nginx
+server {
+    listen 443 ssl; listen [::]:443 ssl;
+    server_name gotest.monoaf.com;
+
+    ssl_certificate     /etc/letsencrypt/live/gotest.monoaf.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/gotest.monoaf.com/privkey.pem;
+    include snippets/tls-common.conf;
+
+    root /srv/www/vhosts/gotest.monoaf.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/`:
+
+```json
+{"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](/posts/vps-part-3-gogs) covers the git server that the code lives on.