Knotty #
Knotty deploys one self-hosted knot server on a single Hetzner VM. Terraform
creates the server and firewall, Bun scripts load operator config from .env,
and Docker Compose runs the checked-in service stack.
The operator surface is small:
bun run release-imagebuilds and pushes the image.bun run bootstrapprepares a fresh host.bun run tf ...handles normal Terraform and rollout work.
What This Repo Manages #
- One Hetzner VM.
- One public firewall.
- One-time bootstrap over public SSH on port
22. - Steady-state admin access over Tailscale on port
2222. - The checked-in compose stack in
deploy/compose.yaml. - Host data under
/opt/knot.
What You Still Manage #
- Secrets in
.env. - Local Terraform state in
terraform/terraform.tfstate. - DNS for your chosen public hostname.
- Docker Hub credentials and image repository.
- Tailscale auth keys and policy.
- Backups, restores, and restore testing.
Requirements #
- Bun.
- Terraform.
- Docker with Buildx.
- A working SSH agent in the same shell as Bun and Terraform.
- A Hetzner Cloud API token.
- A Docker Hub repository, plus push and pull tokens.
- A Tailscale auth key for first bootstrap.
- A public hostname that points at the server.
Repo Layout #
scripts/: operator entrypoints.terraform/: Hetzner resources and rollout hooks.deploy/compose.yaml: checked-in service stack.vendor/knot-docker: pinned image build context.
The compose stack keeps Caddy inline. There is no checked-in Caddyfile and no
custom Caddy image in this repo.
Important .env Values #
PUBLIC_HOSTNAME: public DNS name for the knot server.TAILSCALE_ADMIN_HOST: Tailscale hostname used after bootstrap.TAILSCALE_AUTH_KEY: one-time bootstrap key.HCLOUD_SSH_KEY_FINGERPRINT: existing Hetzner SSH key fingerprint.KNOT_IMAGE_DIGEST: image digest printed bybun run release-image.KNOT_SERVER_OWNER: owner identity served by the knot.APPVIEW_ENDPOINT: upstream AppView endpoint.
First-Time Setup #
- Initialise the vendored build context.
git submodule update --init --recursive
git submodule status vendor/knot-docker
- Create local operator config and confirm SSH agent access.
cp .env.example .env
ssh-add -l
- Render the staged runtime files and validate the compose stack.
bun run tf render-staging .tmp/staging
docker compose --env-file .tmp/staging/compose.env -f deploy/compose.yaml config
- Initialise Terraform.
bun run tf init
- Build and push the image, then copy the printed digest into
KNOT_IMAGE_DIGESTin.env.
bun run release-image
Fresh-Host Flow #
Use this path only once for a new server.
- Run the bootstrap step. It creates the server and firewall, waits for
cloud-init to finish, installs Docker and Tailscale, joins the tailnet, and
moves host SSH to port
2222.
bun run bootstrap
- After bootstrap finishes, switch to the normal Terraform path.
bun run tf plan
bun run tf apply
bun run tf output
Normal Deploy Flow #
Use this for later changes and upgrades.
- Build and push a new image.
bun run release-image
-
Update
KNOT_IMAGE_DIGESTin.env. -
Review and apply the change.
bun run tf plan
bun run tf apply
The rollout refuses to recreate the compose stack if
/opt/knot/repositories is empty while /opt/knot/server/knotserver.db
already exists. That state usually means repository data is missing, not that
the host is fresh. After taking a backup or snapshot, set
KNOT_ROLLOUT_ALLOW_EMPTY_REPOSITORIES=1 only when an empty repository
directory is intentional.
Useful Commands #
Get the Tailscale deploy host:
bun run tf output -raw deploy_host
Open an admin shell:
ssh root@"$(bun run tf output -raw deploy_host)" -p 2222
Check containers and logs:
docker compose --env-file /opt/knot/compose/.env -f /opt/knot/compose/compose.yaml ps
docker compose --env-file /opt/knot/compose/.env -f /opt/knot/compose/compose.yaml logs --tail=100 knot caddy
systemctl status ssh.service tailscaled docker --no-pager
Host Paths #
/opt/knot/compose: deployed compose files and env files./opt/knot/server: database and server state./opt/knot/repositories: hosted repositories./opt/knot/keys: SSH keys used by the service./opt/knot/caddy_data: Caddy state and certificates.
Rollback #
Rollback is digest-based. Put the last known-good digest back into .env, then
apply again.
grep '^KNOT_IMAGE_DIGEST=' .env
bun run tf apply
Backups #
Back up the host data over the Tailscale admin path:
ssh root@"$(bun run tf output -raw deploy_host)" -p 2222 'tar -C /opt/knot -czf - server repositories keys caddy_data' > knot-backup-$(date +%Y%m%d-%H%M%S).tar.gz
Back up .env and terraform/terraform.tfstate separately. The archive above
does not include them.
Before any recovery, server replacement, or rollout that might touch host data, create an off-host checkpoint. A Hetzner snapshot is the quickest whole-VM checkpoint:
server_id="$(jq -r '.resources[] | select(.type=="hcloud_server" and .name=="knot") | .instances[0].attributes.id' terraform/terraform.tfstate)"
curl -fsS \
-X POST \
-H "Authorization: Bearer ${HCLOUD_TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"description":"knot-prod pre-change snapshot","type":"snapshot","labels":{"service":"knot","reason":"pre-change"}}' \
"https://api.hetzner.cloud/v1/servers/${server_id}/actions/create_image"
Record the returned action ID, image ID, timestamp, and the backup archive checksum in the recovery notes before making the change.
Restore #
Copy your backup archive to the host as /root/knot-backup.tar.gz, then run:
docker compose --env-file /opt/knot/compose/.env -f /opt/knot/compose/compose.yaml down
mv /opt/knot/server /opt/knot/server.before-restore
mv /opt/knot/repositories /opt/knot/repositories.before-restore
mv /opt/knot/keys /opt/knot/keys.before-restore
mv /opt/knot/caddy_data /opt/knot/caddy_data.before-restore
tar -C /opt/knot -xzf /root/knot-backup.tar.gz
chown -R 1000:1000 /opt/knot/server /opt/knot/repositories
chmod 0700 /opt/knot/keys
docker compose --env-file /opt/knot/compose/.env -f /opt/knot/compose/compose.yaml up -d
Verify the restored host before deleting any .before-restore directories.
Troubleshooting #
DNS Or TLS #
Your public hostname must resolve to the VM before Caddy can issue a certificate.
dig +short A <public-hostname>
dig +short AAAA <public-hostname>
curl -I https://<public-hostname>
docker compose --env-file /opt/knot/compose/.env -f /opt/knot/compose/compose.yaml logs --tail=100 caddy
Tailscale Or Admin SSH #
If Tailscale did not come up, inspect it on the host or through the Hetzner console.
tailscale status
journalctl -u tailscaled -n 100 --no-pager
If first bootstrap failed, fix the auth key or policy and rerun:
bun run bootstrap
Do not use bun run bootstrap for routine deploys.
Docker Pull Or Login #
Check the username locally, then retry the pull on the host.
grep '^DOCKERHUB_USERNAME=' .env
docker compose --env-file /opt/knot/compose/.env -f /opt/knot/compose/compose.yaml pull
If the token is wrong, fix .env and rerun bun run tf apply.
Public SSH Or Knot Access #
ssh -Tv git@<public-hostname> -p 22
docker compose --env-file /opt/knot/compose/.env -f /opt/knot/compose/compose.yaml logs --tail=100 knot
journalctl -u ssh.service -n 100 --no-pager
Also confirm that the SSH key you are testing with is attached to the account
that matches KNOT_SERVER_OWNER.
Runtime Paths #
grep '^KNOT_' /opt/knot/compose/knot.env
ls -ald /opt/knot/server /opt/knot/repositories /opt/knot/keys /opt/knot/caddy_data
ls -l /opt/knot/server/knotserver.db
Disk Space #
df -h / /opt/knot
du -sh /opt/knot/server /opt/knot/repositories /opt/knot/caddy_data
Limits #
- One VM, no HA, no load balancer, and no blue-green deploy path.
- Terraform state is local.
- First bootstrap requires public SSH on port
22. - Rollback is manual and digest-based.