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.
Repo structure
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 static site (e.g. example.com)
compose.yml
garage/ # Garage S3-compatible object storage
compose.yml
Dockerfile
garage.toml
.env.keys
scripts/
bootstrap.sh # First-time server setup (manual, uses Bitwarden)
configure.sh # Idempotent server config (CI-safe)
deploy.sh # Deploy + cert issuance + provision (called via SSH)
tools/
new-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)
.gitea/workflows/ # Per-app deploy workflows triggered by path changes
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 system dependencies (jq, certbot, unzip, Docker)
- Installs the
bwsCLI (Bitwarden Secrets Manager) - Prompts for a Bitwarden Secrets Manager access token (saved to
/etc/bws-token) - Fetches the Docker registry token and deploy SSH public key via
bws - Logs into the Gitea Docker registry
- Creates the
deployuser with restricted SSH access and sudo - Runs
configure.sh(firewall, certbot, start services)
configure.sh is idempotent and CI-safe. It:
- Opens firewall ports (HTTP, HTTPS, Garage RPC)
- Sets up certbot renewal hooks and timer
- Deploys all services via
deploy.sh(nginx first, then all apps)- Generates
.envfiles from bws for services with.env.keys - Issues SSL certs for new domains (nginx only)
- Builds or pulls images, starts containers
- Generates
Prerequisites:
- DNS for all configured domains must point to the server
- Bitwarden Secrets Manager access token for the
hantim-servermachine account
Adding a new app
./tools/new-app.sh <domain>
Example: ./tools/new-app.sh example.com
This single command handles everything: creates DNS records on Vultr, creates
the Gitea repo from static-site-template, generates all config files, issues
the SSL cert (zero downtime), commits and pushes, triggers the first build,
and verifies the site is live.
Prerequisites: domain nameservers pointed to Vultr, bws/jq/dig
installed. Uses the deploy SSH key from Bitwarden Secrets Manager -- no
personal server access needed. See USECASES.md for full details.
new-app.sh also creates a Garage media bucket for the domain, allowing
media files to be served at https://www.<domain>/media/.
After the site is live, clone the app repo and customize it:
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 like example.com):
- The app's
build.ymlbuilds the Docker image, pushes to the registry, and SSHes into the server to trigger a deploy
Compose or nginx config change (push to this repo):
- Gitea Actions workflows trigger based on which
docker/<app>/paths changed deploy.shpulls the latest code and runsdocker compose up -d- For nginx deploys, the config is tested before applying to prevent downtime
Garage cluster initialization
After provisioning a fresh server, Garage starts and connects to argento
automatically via bootstrap_peers. However, the cluster layout must be
assigned manually (node IDs change on fresh installs):
# Check both nodes are connected
docker exec garage /garage status
# Assign roles (use the node IDs from status output)
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
This only needs to be done once. If only hantim is reprovisioned, argento retains the layout and hantim reconnects automatically.
Media hosting
Media files (images, videos) 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 withmedia-key)garage.hantim.net— Admin API for bucket managementhttps://www.<domain>/media/<path>— public file access
Uploading media:
# Configure AWS CLI (one-time)
aws configure # key ID, secret key from bws, region: garage
aws configure set default.endpoint_url https://s3.hantim.net
# Upload files
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"
Restoring from backup
Run the same three commands as provisioning. bootstrap.sh is idempotent:
- Existing packages are skipped
- Existing certs are skipped (or re-issued if the server is new)
- All services are started
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 | new-app.sh |
hantim-new-app-script |
Gitea API token for creating repos | new-app.sh |
hantim-vultr-api-key |
Vultr API key for DNS management | new-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, new-app.sh |
hantim-garage-media-key-id |
S3 access key ID for media uploads | new-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 a .env.keys file (format:
ENV_VAR=bws-secret-name, one per line). deploy.sh reads this file,
fetches each secret from bws, and generates a .env file before starting
the service. See docker/garage/.env.keys for an example.
Gitea org-level (hantim) secrets/variables:
| Name | Type | Purpose |
|---|---|---|
DEPLOY_HOST |
Variable | Server IP or hostname |
DEPLOY_SSH_KEY |
Secret | SSH private key for the deploy user |
CI_REGISTRY_TOKEN |
Secret | Gitea token for Docker registry login |