Terraform and Bun scripts to deploy one self-hosted Knot server to Hetzner VM
knot tailscale typescript bun terraform hetzner
TypeScript 81%
HCL 19%

README.md

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-image builds and pushes the image.
  • bun run bootstrap prepares 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 #

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 by bun run release-image.
  • KNOT_SERVER_OWNER: owner identity served by the knot.
  • APPVIEW_ENDPOINT: upstream AppView endpoint.

First-Time Setup #

  1. Initialise the vendored build context.
git submodule update --init --recursive
git submodule status vendor/knot-docker
  1. Create local operator config and confirm SSH agent access.
cp .env.example .env
ssh-add -l
  1. 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
  1. Initialise Terraform.
bun run tf init
  1. Build and push the image, then copy the printed digest into KNOT_IMAGE_DIGEST in .env.
bun run release-image

Fresh-Host Flow #

Use this path only once for a new server.

  1. 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
  1. 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.

  1. Build and push a new image.
bun run release-image
  1. Update KNOT_IMAGE_DIGEST in .env.

  2. 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.