hantim-server

Server provisioning and app management for the hantim webserver. This repo lives at /opt/hantim on the server and contains Docker Compose configs, deploy scripts, nginx configs, and Gitea Actions workflows for each app.

See ARCHITECTURE.md for technical deep-dives, USECASES.md for expected behaviors.

Infrastructure overview

Two servers and a cloud secrets service:

  • Hantim (Vultr, Rocky Linux 9) — production web server running nginx, app containers, and a Garage storage node via Docker Compose
  • Argento (home server) — Gitea instance (git.timothykim.net) with Docker registry, CI runner, and a second Garage storage node (with web UI)
  • Bitwarden Secrets Manager (cloud) — all secrets, fetched via the bws CLI

All app containers share a shared Docker network. Only nginx binds host ports (80/443). Garage provides S3-compatible media storage replicated across both nodes.

Repo structure

scripts/
  bootstrap.sh         # First-time server setup (manual, uses Bitwarden)
  configure.sh         # Idempotent server config (CI-safe)
  deploy.sh            # Deploy + provision (called via SSH)
tools/
  app.sh               # Add a new static site (run from dev machine)
  new-service.sh       # Add a new Docker service (run from dev machine)
  remove-app.sh        # Remove a static site (run from dev machine)
docker/
  nginx/               # Reverse proxy (nginx:alpine) + SSL config
    nginx.conf         # Main nginx config
    conf.d/            # Per-app server blocks (e.g. example.com.conf)
    compose.yml
  <domain>/            # Per-app compose files (e.g. example.com)
    compose.yml
  garage/              # Garage S3-compatible object storage
    compose.yml
    Dockerfile
    garage.toml
    .env.keys
tests/                 # pytest integration tests (run post-deploy)
.gitea/workflows/      # Per-app deploy workflows + provision.yml

Provisioning a new server

On a fresh Rocky Linux 9 install:

dnf install -y git
git clone https://git.timothykim.net/hantim/hantim-server.git /opt/hantim
bash /opt/hantim/scripts/bootstrap.sh

bootstrap.sh runs once manually. It installs dependencies, sets up the bws CLI, creates the deploy user, and calls configure.sh.

configure.sh is idempotent and CI-safe. It opens firewall ports, sets up certbot renewal, and deploys all services (nginx first, then apps).

Prerequisites:

  • DNS for all configured domains must point to the server
  • Bitwarden Secrets Manager access token for the hantim-server machine account

Restoring from backup is identical — run the same three commands on a fresh server. Everything is idempotent.

Adding a new static site

./tools/app.sh all <domain>

This single command handles everything: DNS records (Vultr), Gitea repo (from static-site-template), nginx/compose/workflow configs, SSL cert, Garage media bucket, first build, and verification.

Prerequisites: domain nameservers pointed to Vultr, bws/jq/dig installed, BWS_ACCESS_TOKEN env var or token at ~/.config/hantim/bws-token.

After the site is live, clone the app repo and customize:

git clone git@git.timothykim.net:hantim/<domain>.git
cd <domain>
# edit content
git add . && git commit -m "initial content" && git push

Deploying changes

App code change — push to an app repo (e.g., example.com):

  • build.yml builds the image, pushes to registry, SSHes to server to deploy

Compose/nginx config change — push to this repo:

  • Path-matched workflows trigger and SSH to server
  • nginx deploys test config before applying (nginx -t)

Setup change — push changes to scripts/configure.sh:

  • provision.yml triggers and re-runs configure.sh

Media hosting

Media files are stored in Garage and served via nginx at /media/ on each domain. This keeps large files out of git repos.

Endpoints:

  • s3.hantim.net — S3 API for uploads (authenticated)
  • garage.hantim.net — Admin API for bucket management
  • https://www.<domain>/media/<path> — public file access

Uploading:

# One-time AWS CLI setup
aws configure  # key ID + secret from bws, region: garage, output: json
# Then add to ~/.aws/config under [default]:
#   endpoint_url = https://s3.hantim.net

# Upload
aws s3 cp photo.jpg s3://example.com/photo.jpg
aws s3 sync static/media/ s3://example.com/

Local development: store media in static/media/ (gitignored) so files are accessible at /media/ when serving locally.

Creating buckets manually (for existing domains without buckets):

# Get admin token and media key ID from bws
# Create bucket
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"globalAlias": "example.com"}' https://garage.hantim.net/v2/CreateBucket
# Note the bucket ID from the response, then:
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"bucketId": "BUCKET_ID", "accessKeyId": "MEDIA_KEY_ID", "permissions": {"read": true, "write": true, "owner": false}}' \
  https://garage.hantim.net/v2/AllowBucketKey
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"websiteAccess": {"enabled": true, "indexDocument": "index.html"}}' \
  "https://garage.hantim.net/v2/UpdateBucket?id=BUCKET_ID"

Garage cluster initialization

After provisioning a fresh server, Garage connects to argento automatically. The cluster layout must be assigned manually once (node IDs change per install):

docker exec garage /garage status
docker exec garage /garage layout assign <hantim-node-id> -z hantim -c 1G
docker exec garage /garage layout assign <argento-node-id> -z argento -c 1G
docker exec garage /garage layout apply --version 1

If only hantim is reprovisioned, argento retains the layout and hantim reconnects automatically.

Testing

Tests live in tests/ and run with pytest:

pip install pytest
pytest tests/ -v

Tests auto-discover sites from docker/nginx/conf.d/*.conf — no list to maintain when adding new sites.

The deploy-nginx.yml workflow runs tests automatically after each nginx deploy. If tests fail, Gitea sends an email notification.

Secrets

Bitwarden Secrets Manager

Project: hantim. Fetched via bws CLI on the server.

Secret Purpose Used by
hantim-ci-registry-push Gitea token for Docker registry bootstrap.sh
hantim-deploy-ssh-public-key Deploy user's SSH public key bootstrap.sh
hantim-deploy-ssh-private-key Deploy user's SSH private key app.sh
hantim-new-app-script Gitea API token for creating repos app.sh
hantim-vultr-api-key Vultr API key for DNS management app.sh
hantim-garage-rpc-secret Garage cluster RPC secret deploy.sh
hantim-garage-argento-node-id Argento's Garage node ID + address deploy.sh
hantim-garage-admin-token Garage admin API token deploy.sh, app.sh
hantim-garage-media-key-id S3 access key ID for media uploads app.sh, aws CLI
hantim-garage-media-secret-key S3 secret key for media uploads aws CLI

Machine accounts: hantim-server (token at /etc/bws-token), hantim-ci (reserved).

Service secrets (.env.keys)

Services that need secrets declare them in .env.keys (format: ENV_VAR=bws-secret-uuid, one per line, with # secret-name comments). deploy.sh fetches each secret by UUID from bws and generates .env before starting the service.

Gitea org-level secrets

Stored in the hantim Gitea org for direct use in CI workflows:

Name Type Purpose
DEPLOY_HOST Variable Server IP or hostname
DEPLOY_HOST_KEY Variable Server SSH host public key (for host key pinning)
DEPLOY_SSH_KEY Secret SSH private key for the deploy user
CI_REGISTRY_TOKEN Secret Gitea token for Docker registry login

After reprovisioning hantim (new host key), update DEPLOY_HOST_KEY:

ssh-keyscan -t ed25519 hantim.net 2>/dev/null
S
Description
No description provided
Readme 500 KiB
Languages
Shell 65.2%
Python 33.5%
Assembly 0.5%
Dockerfile 0.4%
HTML 0.4%