5.5 KiB
Hetzner + k3s + self-hosted Git/CI bootstrap
Goal: provision and deploy everything from this repo to a single Hetzner machine with no manual server login.
Stack
- Terraform provisions the Hetzner Cloud VM, private network, and firewall
- cloud-init installs Tailscale first when configured, then installs k3s automatically
- Kubernetes manifests deploy:
- Redpanda
- trading system services
- private registry
- Forgejo
- ingress-nginx
- cert-manager
- ACME issuers
- local bootstrap script:
- runs Terraform
- optionally creates DNS records via Cloudflare or Porkbun
- writes overlay secrets/host patches from local env
- applies the Hetzner single-node k8s overlay
- builds the current app image locally
- fetches the real kubeconfig from the node
- imports the bootstrap image into k3s for the first rollout
Files
infra/terraform/hetzner/deploy/k8s/base/deploy/k8s/overlays/hetzner-single-node/scripts/hetzner/bootstrap.shscripts/hetzner/configure-cloudflare-dns.shscripts/hetzner/destroy.shscripts/k8s/logs.sh.forgejo/workflows/deploy.yml
Required local tools
terraformkubectldockercurlpython3gitpass
Required local env
Start from:
cp scripts/hetzner/bootstrap-secrets.env.example scripts/hetzner/bootstrap-secrets.env
${EDITOR:-vi} scripts/hetzner/bootstrap-secrets.env
source scripts/hetzner/bootstrap-secrets.env
The mapping file should contain non-secret config plus pass entry references for secrets. Bootstrap and destroy load the first line from each configured pass entry without echoing it. Explicit env exports still override pass lookups.
Required values:
HCLOUD_TOKEN_PASSorHCLOUD_TOKENSSH_PUBLIC_KEY_PATHPUBLIC_DOMAINBASE_DOMAIN- recommended Tailscale values:
TAILSCALE_AUTH_KEY_PASSorTAILSCALE_AUTH_KEYTAILSCALE_CONTROL_PLANE_HOSTNAME
FORGEJO_DOMAINFORGEJO_ROOT_URLREGISTRY_DOMAINLETSENCRYPT_EMAILREGISTRY_USERNAMEREGISTRY_PASSWORD_PASSorREGISTRY_PASSWORDNEAR_INTENTS_API_KEY_PASSorNEAR_INTENTS_API_KEYFORGEJO_ADMIN_USERNAMEFORGEJO_ADMIN_EMAILFORGEJO_ADMIN_PASSWORD_PASSorFORGEJO_ADMIN_PASSWORD- optional generated-secret target:
FORGEJO_RUNNER_REGISTRATION_TOKEN_PASS - optional repo settings:
FORGEJO_REPO_OWNER,FORGEJO_REPO_NAME,FORGEJO_REPO_PRIVATE
Optional for automatic DNS:
- Cloudflare:
CLOUDFLARE_API_TOKEN_PASSorCLOUDFLARE_API_TOKENCLOUDFLARE_ZONE_ID_PASSorCLOUDFLARE_ZONE_ID
- Porkbun:
PORKBUN_API_KEY_PASSorPORKBUN_API_KEYPORKBUN_SECRET_API_KEY_PASSorPORKBUN_SECRET_API_KEY
Bootstrap
bash scripts/hetzner/bootstrap.sh
Outputs:
- Hetzner VM created
- Tailscale joined if configured
- k3s installed
- kubeconfig written to
.state/hetzner/kubeconfig.yaml - CI kubeconfig written to
.state/hetzner/kubeconfig.incluster.yaml - overlay secrets and ingress host patches rendered from local env /
pass - namespaces, Redpanda, app deployments, Forgejo, registry, ingress, cert-manager, and issuers applied
- Forgejo admin account created automatically if missing
- Forgejo runner registration token generated automatically and stored in the live Kubernetes secret
- Forgejo repository created automatically
- Forgejo Actions secrets and variables configured automatically
- repo pushed to Forgejo automatically in the default
forgejo-actionsdelivery mode - first deployment triggered from Forgejo Actions by default
Tailscale-first admin access
Recommended mode:
- public firewall exposes only
80/443 - admin access uses Tailscale
- Kubernetes API uses the Tailscale hostname when
TAILSCALE_CONTROL_PLANE_HOSTNAMEis set
TF_ADMIN_CIDR_BLOCKS remains only as a fallback if you intentionally want public admin/API exposure.
DNS and TLS
If DNS provider credentials are present, bootstrap updates:
${BASE_DOMAIN}git.${BASE_DOMAIN}registry.${BASE_DOMAIN}
Supported scripted providers:
- Cloudflare
- Porkbun
TLS is handled in-cluster by cert-manager using Let's Encrypt issuers and the rendered ingress hosts.
Observe the cluster
KUBECONFIG=.state/hetzner/kubeconfig.yaml kubectl get pods -A
bash scripts/k8s/logs.sh
Self-hosted CI/CD handoff
Default bootstrap now automates the Forgejo handoff:
- create the Forgejo repo
- configure the repository Actions secrets:
KUBECONFIG_B64REGISTRY_USERNAMEREGISTRY_PASSWORD
- configure the repository Actions variables:
REGISTRY_HOST=${REGISTRY_DOMAIN}PROJECT_NAMEPROJECT_NAMESPACEPROJECT_DEPLOYMENTS
- push the current repo to
main
The workflow then:
- starts a Kubernetes Job in the target namespace
- uses Kaniko plus the Kubernetes registry auth secret to build and push
${REGISTRY_DOMAIN}/${PROJECT_NAME}:${GIT_SHA} - updates the app deployments in
PROJECT_NAMESPACE - waits for rollout
Legacy local-image bootstrap remains available with:
BOOTSTRAP_DELIVERY_MODE=local-image-import bash scripts/hetzner/bootstrap.sh
Destroy everything
source scripts/hetzner/bootstrap-secrets.env
bash scripts/hetzner/destroy.sh
destroy.sh reads HCLOUD_TOKEN and optional TAILSCALE_AUTH_KEY via the same *_PASS mapping mechanism as bootstrap.
Current limitations
- automated repo creation currently assumes
FORGEJO_REPO_OWNER == FORGEJO_ADMIN_USERNAME - bootstrap still uses local
dockerto generate the registry htpasswd secret - bootstrap and CI authentication paths should still be hardened before production use