Files
hantim-server/ARCHITECTURE.md
T
timothykim 0f951281d5
Provision server / provision (push) Successful in 22s
self-heal standalone cert renewal configs on provision
2026-06-08 11:35:37 -04:00

203 lines
7.1 KiB
Markdown

# Architecture
Technical details for the hantim infrastructure. See README.md for usage
instructions, USECASES.md for expected behaviors.
## Servers
### Hantim (Vultr, Rocky Linux 9)
Production web server. This repo is cloned to `/opt/hantim`.
- Runs nginx reverse proxy + all app containers via Docker Compose
- Certbot runs on the host (not in Docker) and manages Let's Encrypt certs
- The `deploy` user receives SSH commands from CI to trigger deploys
- Garage storage node for media replication
### Argento (home server)
- Gitea instance at `git.timothykim.net`
- Docker container registry (`git.timothykim.net/hantim/<app>`)
- Gitea Actions CI runner (registered at site level)
- Second Garage storage node (replication partner)
- Garage web UI for administration
### Bitwarden Secrets Manager (cloud)
All secrets stored in project `hantim`, fetched via `bws` CLI.
Machine accounts:
- `hantim-server` — used by `bootstrap.sh` and `configure.sh` (token at `/etc/bws-token`)
- `hantim-ci` — reserved for future CI use
Gitea org-level secrets (`hantim`) duplicate some bws secrets for direct
CI use without installing `bws`: `DEPLOY_SSH_KEY`, `CI_REGISTRY_TOKEN`,
`DEPLOY_HOST` (variable).
## Network topology
```
Internet
|
[Hantim Server]
|
nginx (port 80/443)
/ | | \
example_com app_two garage ...
(shared Docker network)
```
- nginx is the only container with host port bindings (80, 443)
- All app containers are on the `shared` Docker network
- nginx proxies `https://www.<domain>` to the app container by name
- The `shared` network is created by the nginx compose file and referenced
as `external: true` by all other compose files
## Deploy flow
### App code change (push to app repo)
```
Developer pushes to app repo (e.g., example.com)
-> build.yml runs on Gitea runner
-> docker build + push to registry
-> SSH to deploy@hantim as deploy-<domain>
-> deploy.sh: git pull, docker compose pull, docker compose up -d
```
### Infrastructure change (push to hantim-server)
```
Developer pushes to hantim-server
-> deploy-<app>.yml triggers (path match on docker/<app>/)
-> SSH to deploy@hantim as deploy-<app>
-> deploy.sh: git pull, docker compose pull, docker compose up -d
-> nginx: auto-issues certs for new domains, tests config with nginx -t
```
### Setup change (push to hantim-server)
```
Developer pushes change to scripts/configure.sh
-> provision.yml triggers
-> SSH to deploy@hantim as provision
-> deploy.sh: git pull, runs configure.sh
```
## SSH deploy protocol
The `deploy` user's `authorized_keys` uses `command=` restrictions. The SSH
client sends a command string (e.g., `deploy-example.com`), and `deploy.sh`
parses it to determine the action:
- `deploy-<app>` — deploy a specific app
- `deploy-nginx` — deploy nginx (fails if certs are missing)
- `cert-<domain>` — issue a cert only (via `certbot --webroot`, zero downtime; subdomains like `garage.hantim.net` skip the `www.` variant)
- `provision` — run `configure.sh`
## SSL certificate handling
Cert names are derived from nginx conf filenames (e.g., `example.com.conf`
cert `example.com`). All `server_name` values in the conf are included in the
cert as SANs.
**New domain:** issue cert before deploying nginx config:
1. Run `app.sh cert <domain>` (uses `certbot --webroot`, zero downtime)
2. Deploy nginx config — `deploy.sh` verifies certs exist, fails if missing
**Renewal:** certbot timer/cron runs daily, deploy hook reloads nginx. All certs
must use the `webroot` authenticator so nginx can keep port 80; certs issued by
the old `--standalone` flow (removed in `de1d60b`) can never renew because nginx
holds port 80. `configure.sh` self-heals this on every `provision`: it scans
`/etc/letsencrypt/renewal/*.conf` and reissues any `standalone` cert via the
`cert-<domain>` webroot path (best-effort; warns and exits non-zero on failure).
## `.env.keys` mechanism
Services needing secrets declare them in `.env.keys`:
```
# bws-secret-name
ENV_VAR=bws-secret-uuid
```
During deploy, `deploy.sh`:
1. Reads each line from `.env.keys`
2. Fetches the secret value from bws: `bws secret get <uuid> --access-token ...`
3. Writes `ENV_VAR=value` to `.env`
4. Docker Compose reads `.env` on startup
If any secret is missing, deploy fails with an error (not a warning).
## Naming conventions
- **App name = domain** (e.g., `example.com`)
- **Container name** = domain with dots → underscores (e.g., `example_com`)
- **Image name** = `git.timothykim.net/hantim/<domain>:latest`
- **Cert name** = nginx conf filename without `.conf`
- **One domain per conf file** — `deploy.sh` derives the cert name from the
filename and collects all `server_name` values for that cert
## GoatCounter (analytics)
GoatCounter runs at `goatcounter.hantim.net` for privacy-friendly web analytics.
**Script injection:** nginx injects the GoatCounter tracking script into all
HTML responses via `sub_filter`. The shared include `conf.d/goatcounter.inc`
is added to each site's main HTTPS server block. New sites get it automatically
via `app.sh`.
**Config:** `docker/goatcounter/compose.yaml` uses the `baethon/goatcounter`
image. Password is stored in Bitwarden (`hantim-goatcounter-password`) and
injected via the `.env.keys` mechanism.
## URL routing
- All HTTP → HTTPS redirect
- Bare domain HTTPS → `https://www.<domain>` redirect
- Each domain gets its own conf file (even redirect-only domains)
## Garage (S3-compatible storage)
Two-node cluster: hantim + argento, `replication_factor = 2`.
**Components:**
- Garage v2.2.0 (`dxflrs/garage:v2.2.0`)
- Port 3900: S3 API (proxied via `s3.hantim.net`)
- Port 3901: RPC (inter-node, firewall opened)
- Port 3902: Web endpoint (serves files via `Host: <bucket>.web.garage`)
- Port 3903: Admin API (proxied via `garage.hantim.net`)
**Media serving path:**
```
Client -> nginx (443) -> /media/ location -> garage:3902
(proxy_set_header Host <domain>.web.garage)
```
**Config generation:** `garage.toml` uses `envsubst` placeholders (`${RPC_SECRET}`,
`${ARGENTO_NODE_ID}`, `${ADMIN_TOKEN}`). The Dockerfile copies the template and
runs envsubst at container start. Values come from `.env` generated by the
`.env.keys` mechanism.
**Bucket setup** (done by `app.sh` via admin API v2):
1. `POST /v2/CreateBucket` with `globalAlias: <domain>`
2. `POST /v2/AllowBucketKey` to grant media-key read/write access
3. `POST /v2/UpdateBucket` to enable website access
## App repos
Every site repo follows the same pattern (created from `static-site-template`):
```
Dockerfile # Copies static/ into nginx:alpine, applies nginx.conf
nginx.conf # Per-app HTTP config (port 80, routing, cache headers)
static/ # HTML/CSS/JS content
static/media/ # Local media files (gitignored)
media.sh # Sync media with Garage (pull/push)
.gitea/workflows/build.yml # Build image, push to registry, SSH deploy
```
The template uses Gitea template variables (`${REPO_NAME}`) so the image tag
and deploy command match the repo name automatically. Workflow files must
escape `$$` to prevent template variable consumption.