Hetzner: Kubernetes for $4.10 a Month
/ 9 min read
Table of Contents
Why did I do this!?
-
I’ve been self-hosting an email newsletter from home. With power outages, internet outages, and home server outages, the total downtime was getting to be a bit much for my taste. I wanted to deploy to the cloud for better uptime.
-
I’m cheap. I had spun up a two-node Kubernetes cluster with a load balancer on DigitalOcean, and that was costing about $36 a month. It didn’t feel like I was getting much for my money.
-
I like declarative deployments.
When I learned I could run Kubernetes for $4.10 a month on Hetzner, I decided it checked all my boxes.
- Better uptime (compared to my homelab) ✔️
- Cheap ✔️
- Declarative deployments ✔️
So, I decided to go down a rabbit hole and deploy Keila with Kubernetes on Hetzner. This post will walk through the key things I learned in the process. If you have any suggestions on how I can improve this setup, I’m all ears! I plan to add additional services like Uptime Kuma to my “cluster”.
Prerequisites
Here’s a quick list of everything I needed to set up:
- A Hetzner Cloud account
- A domain managed by Cloudflare
- An SSH key pair (
~/.ssh/id_ed25519) - Install hetzner-k3s
- Install kubectl, helm, helmfile, and the helm-secrets and helm-diff plugins
- Install SOPS and age for encrypting secrets
- An SMTP provider for sending emails (e.g. Mailgun, Amazon SES, Postmark)
For fellow macOS users, here are the commands I used to install these tools:
brew install vitobotta/tap/hetzner_k3sbrew install kubectlbrew install helmbrew install helmfileexport PATH="$PATH:/opt/homebrew/bin"
# helm-secrets has 3 componentshelm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v4.7.5/secrets-4.7.5.tgz --verify=falsehelm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v4.7.5/secrets-getter-4.7.5.tgz --verify=falsehelm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v4.7.5/secrets-post-renderer-4.7.5.tgz --verify=false
helm plugin install https://github.com/databus23/helm-diff --verify=false
curl -L -o sops https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.darwin.arm64chmod +x sopssudo mv sops /usr/local/bin/
brew install age1. Create Hetzner API Token
- Log in to Hetzner Cloud Console
- Create a new project (e.g.
k3s) - Go to Security → API tokens → Generate API token
- Give it Read & Write permissions
- Save the token
2. Create the Cluster
Create a file called cluster.yaml:
cluster_name: k3s
kubeconfig_path: "./kubeconfig"
k3s_version: v1.35.1+k3s1
networking:
ssh:
port: 22
use_agent: false
public_key_path: "~/.ssh/id_ed25519.pub"
private_key_path: "~/.ssh/id_ed25519"
allowed_networks:
ssh:
- 0.0.0.0/0
- ::/0
api:
- 0.0.0.0/0
- ::/0
public_network:
ipv4: true
ipv6: true
private_network:
enabled: true
subnet: 10.0.0.0/16
existing_network_name: ""
custom_firewall_rules:
- description: "Allow HTTP"
direction: in
protocol: tcp
port: 80
source_ips:
- 0.0.0.0/0
- ::/0
- description: "Allow HTTPS"
direction: in
protocol: tcp
port: 443
source_ips:
- 0.0.0.0/0
- ::/0
schedule_workloads_on_masters: true
masters_pool:
instance_type: cx23
instance_count: 1
locations:
- nbg1
worker_node_pools: []
addons:
csi_driver:
enabled: false
local_path_storage_class:
enabled: true
traefik:
enabled: true
servicelb:
enabled: true
protect_against_deletion: true
A few things to note:
- IPv4 included. I was tempted to save $0.60/month by going IPv6-only (
public_network.ipv4: false), but IPv4 means you can SSH in from any network, all visitors can reach your services regardless of their ISP, and you don’t have to troubleshoot IPv6 connectivity issues. Overall, I think it’s money well-spent. schedule_workloads_on_masters: trueis required for a single-node cluster so pods can run on the master node.- CSI driver disabled. We’re using k3s’s built-in local-path-provisioner instead of Hetzner block storage. Data is stored directly on the node’s disk at
/var/lib/rancher/k3s/storage/. This saves money but means data is lost if the node’s disk fails. - Traefik and ServiceLB enabled. Traefik handles ingress routing; ServiceLB exposes ports 80 and 443 on the node.
- Firewall. Hetzner’s Cloud Firewall allows only SSH, HTTP, HTTPS, and the Kubernetes API. Everything else is blocked.
Note that hetzner_token is not in the config file. Instead, hetzner-k3s reads it from the HCLOUD_TOKEN environment variable.
Create the cluster:
export HCLOUD_TOKEN=<your-hetzner-api-token>hetzner-k3s create --config cluster.yaml | tee create.logThis takes a few minutes. When finished, your kubeconfig is saved to ./kubeconfig:
export KUBECONFIG=./kubeconfigkubectl get nodesYou can check the available k3s versions with hetzner-k3s releases.
hetzner-k3s deploys a cluster autoscaler, but it’s not needed for a single-node cluster with no worker pools. It will sit in CrashLoopBackOff indefinitely, so I chose to remove it:
kubectl delete deployment cluster-autoscaler -n kube-system3. Create DNS Record on Cloudflare
Get your server’s IPv4 address:
kubectl get nodes -o wideLook for the EXTERNAL-IP column.
In the Cloudflare dashboard:
- Select your domain
- Go to DNS → Records → Add Record
- Create a wildcard A record:
- Type:
A - Name:
*.k3s - IPv4 address: your server’s IP address
- Proxy status: DNS only (grey cloud)
- Type:
- Click Save
Now any subdomain of k3s.andrewmarder.net resolves to your server. Adding a new service later is just creating a new Kubernetes Ingress — no DNS changes needed.
4. Project Structure
We use Helmfile to manage all cluster services declaratively. The project is organized as follows:
.├── .sops.yaml # SOPS config (age public key)├── cluster.yaml # hetzner-k3s cluster config (no secrets)├── helmfile.yaml # declares all Helm releases├── charts/│ ├── cluster-tls/ # ClusterIssuer + wildcard cert + TLSStore│ │ ├── Chart.yaml│ │ ├── values.yaml│ │ └── templates/│ │ ├── cloudflare-secret.yaml│ │ ├── cluster-issuer.yaml│ │ ├── tls-store.yaml│ │ └── wildcard-certificate.yaml│ └── keila/ # Keila app + Postgres│ ├── Chart.yaml│ ├── values.yaml│ └── templates/│ ├── ingress.yaml│ ├── keila-deployment.yaml│ ├── keila-secrets.yaml│ ├── keila-service.yaml│ ├── postgres-deployment.yaml│ ├── postgres-pvc.yaml│ ├── postgres-service.yaml│ └── uploads-pvc.yaml├── secrets/ # SOPS-encrypted (safe to commit)│ ├── cluster-tls.yaml│ ├── hetzner.yaml│ └── keila.yaml└── values/ # plain-text config (no secrets) ├── cluster-tls.yaml └── keila.yamlThe key ideas:
helmfile.yamlis the single source of truth for what’s deployed. Adding a service means adding a chart (or reusing an existing one), a values file, and a secrets file, then runninghelmfile apply.secrets/files are SOPS-encrypted with age. They’re safe to commit to git. The helm-secrets plugin decrypts them on the fly duringhelmfile apply.values/files are plain-text configuration (image versions, storage sizes, schedules) — no sensitive data.
Here’s my helmfile.yaml:
repositories:
- name: jetstack
url: https://charts.jetstack.io
releases:
- name: cert-manager
namespace: cert-manager
createNamespace: true
chart: jetstack/cert-manager
version: v1.17.2
values:
- crds:
enabled: true
- name: cluster-tls
namespace: cert-manager
chart: ./charts/cluster-tls
needs:
- cert-manager/cert-manager
values:
- ./values/cluster-tls.yaml
secrets:
- ./secrets/cluster-tls.yaml
- name: keila
namespace: keila
createNamespace: true
chart: ./charts/keila
needs:
- cert-manager/cluster-tls
values:
- ./values/keila.yaml
secrets:
- ./secrets/keila.yaml
A few things to note:
needsensures releases are installed in order: cert-manager first, then the ClusterIssuer and wildcard certificate, then Keila.createNamespace: truecreates the namespace automatically.secretsreferences SOPS-encrypted files. Helmfile (via helm-secrets) decrypts them at deploy time and merges the values with the plain-textvaluesfiles.- Pinned versions. The cert-manager chart version is pinned; image versions are pinned in the values files.
5. Encrypt Secrets with SOPS + age
All secrets are stored encrypted in the secrets/ directory using SOPS with age encryption. This means secrets live in git alongside your charts — encrypted at rest, decrypted on the fly during helmfile apply.
Generate an age key
age-keygen -o key.txtThis creates key.txt containing your private key and prints the public key (starting with age1...). Store key.txt somewhere safe (e.g. password manager, secure backup). Without it, you can’t decrypt your secrets.
Set the environment variable so SOPS can find your key:
export SOPS_AGE_KEY_FILE=$(pwd)/key.txtConfigure SOPS
Update .sops.yaml with your age public key:
creation_rules: - path_regex: secrets/.*\.yaml$ age: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxCreate the Cloudflare API Token
For certificate issuance, I chose DNS-01 challenges (supported by cert-manager) via Cloudflare. This lets us get a single wildcard certificate for all services and avoids some common routing issues associated with HTTP-01 challenges.
- Go to Cloudflare API Tokens
- Click Create Token → use the Edit zone DNS template
- Under Zone Resources, select your domain
- (Optional) I chose to set up Client IP Address Filtering, setting the Operator to “Is in” and adding my server’s public IP address to the Value list. This way, the API token only works from that one IP address.
- Click Continue to summary → Create Token
Fill in and encrypt the secrets files
Edit secrets/hetzner.yaml with your Hetzner API token:
hetzner_token: <your-hetzner-api-token>Edit secrets/cluster-tls.yaml with your Cloudflare API token:
cloudflare: apiToken: <your-cloudflare-api-token>Edit secrets/keila.yaml with generated passwords and your SMTP credentials:
secrets: postgresPassword: <generate-with-openssl-rand> secretKeyBase: <generate-with-openssl-rand> keilaUser: you@example.com keilaPassword: <generate-with-openssl-rand> mailerSmtpHost: <your-smtp-host> mailerSmtpPort: "587" mailerSmtpUser: <your-smtp-username> mailerSmtpPassword: <your-smtp-password>You can generate random passwords with:
openssl rand -base64 24 | tr -d '/+='Use a longer value for secretKeyBase — Keila requires at least 64 bytes:
openssl rand -base64 64 | tr -d '/+='Encrypt all secrets files in place:
sops -e -i secrets/hetzner.yamlsops -e -i secrets/cluster-tls.yamlsops -e -i secrets/keila.yamlThe files are now safe to commit to git. To view or edit them later:
sops secrets/keila.yaml6. Deploy Everything
A single command installs cert-manager, the wildcard TLS certificate, and Keila. Helmfile decrypts the secrets files on the fly via helm-secrets:
helmfile applyThis installs the releases in dependency order: cert-manager → ClusterIssuer + wildcard certificate → Keila.
7. Verify
Wait a minute or two for everything to start, then check:
# All pods should be Runningkubectl get pods -A
# Wildcard certificate should be Ready (may take a minute for DNS-01 validation)kubectl get certificate -n default
# Check the ingresskubectl get ingress -n keilaOpen https://keila.k3s.andrewmarder.net in your browser and log in with the credentials from your secrets file. Although SMTP is configured at deploy time via secrets/keila.yaml, I still had to create a sender through the web interface.
8. Security
I’m hopeful the setup above covers the security basics. Here’s what’s in place:
- Firewall. This setup allows SSH (22), HTTP (80), HTTPS (443), Kubernetes API (6443), and Kubernetes NodePorts (30000-32767). All other ports are blocked.
- TLS everywhere. A wildcard certificate covers all
*.k3s.andrewmarder.netsubdomains. - Secrets encrypted at rest. All secrets are SOPS-encrypted with age and safe to commit to git.
- No exposed databases. PostgreSQL is only reachable within the cluster via its ClusterIP service.
- Registration disabled.
DISABLE_REGISTRATION=trueprevents strangers from creating Keila accounts.
If there’s additional hardening that’s important, please let me know!
9. Maintenance
Upgrading k3s
hetzner-k3s installs the System Upgrade Controller by default. To upgrade k3s, update k3s_version in cluster.yaml and run:
export HCLOUD_TOKEN=$(sops -d --extract '["hetzner_token"]' secrets/hetzner.yaml)hetzner-k3s upgrade --config cluster.yamlUpdating Container Images
Update the pinned image versions in your values files (e.g. values/keila.yaml), then apply:
helmfile applySyncing After Changes
Any time you modify helmfile.yaml, chart templates, or values files, run helmfile apply to reconcile the cluster state. Use helmfile diff first to preview changes.
10. Cost
| Item | Monthly Cost |
|---|---|
| CX23 (2 vCPU, 4 GB RAM, 40 GB disk) | $3.50 |
| Public IPv4 address | $0.60 |
| Local storage (instead of block storage) | $0.00 |
| Let’s Encrypt certificates | $0.00 |
| Total | $4.10 |
Prices are for the nbg1 (Nuremberg, Germany) location, excluding VAT. Check Hetzner’s pricing page for current rates. You can list available instance types with:
curl -H "Authorization: Bearer $HCLOUD_TOKEN" 'https://api.hetzner.cloud/v1/server_types'11. Adding New Services
The wildcard DNS record and wildcard TLS certificate mean adding a new service requires no DNS or certificate changes. For any new app:
- Create a Helm chart in
charts/<service>/(or use a public chart). Include Secret templates so Helm manages Kubernetes Secrets. - Add a values file at
values/<service>.yaml(plain-text config) - Add a secrets file at
secrets/<service>.yaml, fill in sensitive values, and encrypt it:
sops -e -i secrets/my-new-service.yaml- Add a release to
helmfile.yaml:
- name: my-new-service namespace: my-new-service createNamespace: true chart: ./charts/my-new-service needs: - cert-manager/cluster-tls values: - ./values/my-new-service.yaml secrets: - ./secrets/my-new-service.yaml- Deploy:
helmfile applyEvery service chart should include an Ingress referencing the shared wildcard-tls secret.
References
Links to GitHub projects: