How this site is built
A single virtual server hosts this site and every project alongside it, behind one web server, maintained conversationally by an AI agent running on the same box.
The idea
Most personal projects die in the gap between "it works on my laptop" and "it is on the internet." The goal here was to close that gap permanently: one server, one address, and a repeatable path from idea to live URL that does not involve remembering how any of it works.
The result is an additive host. New projects are added; nothing is torn down to make room. Each one is reachable under the same address at its own path.
Architecture
internet
|
+-------+-------+
| ufw firewall | 22, 80, 443 only
+-------+-------+
|
+-------+-------+
| Caddy | sole listener on 80/443
+---+-------+---+
| |
+--------------+ +--------------+
| |
+-----+------+ +-------+--------+
| static | | dynamic apps |
| files | | 127.0.0.1:70xx|
| /srv/www | | systemd, non- |
| | | root user |
+------------+ +----------------+
-- separate, loopback only -----------------------------
+----------------+ +------------------+
| agent gateway |<------>| chat interface |
| 127.0.0.1 | | (allowlisted) |
+----------------+ +------------------+The stack
How a request is served
Caddy matches the most specific path first and falls through to the root site last. Static projects are served straight off disk; dynamic ones are proxied to a local port that is never exposed publicly.
redir /projectname /projectname/ 308
handle_path /projectname/* {
root * /srv/www/projectname
file_server
}
# dynamic equivalent
handle_path /appname/* {
reverse_proxy 127.0.0.1:7001
}
The distinction that matters is handle_path rather than
handle: it strips the path prefix before the request continues.
Without that, an application mounted at /projectname receives
/projectname/asset.css when it expects /asset.css,
and every asset reference breaks.
Adding a project
The procedure is deliberately identical every time:
- Source goes in
/srv/projects/<name>as its own git repository, pushed to a remote for backup. - Static builds output to
/srv/www/<name>. Dynamic apps get a systemd unit running as an unprivileged service account, bound to loopback on the next free port from 7001. - A routing block is appended to the Caddyfile, which is validated before reload.
- The project is listed on the landing page.
Nothing about this requires taking an existing project offline.
The agent layer
An OpenClaw gateway runs as a service on the same host, backed by a Claude model. It is reached through a messaging channel, which makes the deployment procedure above something that can be requested in a sentence rather than performed by hand.
This is the part with real teeth, so it is worth being precise about the trade-off. An agent that can deploy software is an agent with a shell. The controls that make that acceptable:
- The messaging channel is restricted to a single allowlisted account. Unknown senders are rejected before a message is ever processed, not merely ignored in the reply.
- The gateway binds to loopback only. It is not reachable from the internet; administrative access goes through an SSH tunnel.
- Credentials live in files readable only by their owner, and are excluded from every repository before its first commit.
- Group messaging is disabled outright rather than filtered.
Deliberately not here yet
- Container isolation. Application processes run as an unprivileged user with systemd hardening, which is a meaningful boundary but not a sandbox. Worth adding when something here handles data that belongs to someone else.
- Redundancy. This is one machine. Source is mirrored to a git remote; the server itself is not highly available, and does not need to be.
- Observability. Access logs roll on disk. No metrics pipeline yet — worth adding once there is traffic worth measuring.