12 KiB
Homelab Starter
A minimal GitOps homelab setup using K3s and Flux CD. This starter gives you a working Kubernetes cluster with core infrastructure and one example app (Gitea), all managed declaratively through Git. Secrets are encrypted at rest in Git using SOPS with age.
Architecture
┌──────────────────────────────────────────────────────────┐
│ Git Repository │
│ │
│ bootstrap/ Infrastructure & apps/ │
│ ├── ns/ app definitions └── gitea │
│ ├── repositories/ are organized │ │
│ └── kustomization/ into layers │ │
└──────────┬─────────────────────────────────────┘ │
│ │
▼ │
┌──────────────────┐ Flux watches your Git repo │
│ Flux CD │ and continuously reconciles │
│ (flux-system) │ the cluster state to match. │
└──────────┬───────┘ │
│ │
▼ │
┌──────────────────────────────────────────────────────────┐
│ Kubernetes Cluster (K3s) │
│ │
│ Infrastructure layer Application layer │
│ ┌────────────┐ ┌────────────┐ │
│ │ MetalLB │─── IPs ──────▶ │ Gitea │ │
│ │ Traefik │─── Routing ──▶ │ PostgreSQL │ │
│ │ Cert-Mgr │─── TLS ─────▶ │ │ │
│ └────────────┘ └────────────┘ │
└──────────────────────────────────────────────────────────┘
What's Included
| Component | Purpose |
|---|---|
| MetalLB | Assigns real LAN IPs to LoadBalancer services |
| Traefik | Ingress controller — routes HTTP/HTTPS traffic to apps |
| Cert-Manager | Automatically provisions TLS certificates via Let's Encrypt |
| Gitea | Self-hosted Git service (example app) with PostgreSQL |
Prerequisites
- A Linux machine (or VM) with K3s installed
kubectlconfigured to talk to your cluster- Flux CLI installed locally
- SOPS and age installed locally
- A Git repository (GitHub, Gitea, GitLab, etc.) to host this code
- A domain name pointed at your cluster (for TLS certificates)
- A Cloudflare account (or other DNS provider) for DNS-01 challenges
Quick Start
1. Copy this starter into your own repository
# Create a new repo and copy the starter contents into it
# (include hidden files like .sops.yaml)
cp -r starter/{.,}* ~/my-homelab/
cd ~/my-homelab
git init && git add -A && git commit -m "Initial homelab setup"
git remote add origin <YOUR_GIT_REPO_URL>
git push -u origin main
2. Replace placeholder values
Search for all <YOUR_...> placeholders and replace them with your values:
| Placeholder | File(s) | Description |
|---|---|---|
<YOUR_IP_RANGE> |
infrastructure/metallb-config/ipaddresspool.yaml |
LAN IP range for LoadBalancer services, e.g. 192.168.1.200-192.168.1.210 |
<YOUR_LAN_CIDR> |
infrastructure/traefik-install/traefik-override.yaml |
Your local network CIDR, e.g. 192.168.1.0/24 |
<YOUR_EMAIL> |
infrastructure/cert-manager-issuer/issuer.yaml |
Email for Let's Encrypt notifications |
<YOUR_DNS_API_TOKEN> |
infrastructure/cert-manager-issuer/secrets.yaml |
Cloudflare API token (see below) |
<YOUR_DOMAIN> |
infrastructure/routes/gitea.yaml, apps/gitea/install/gitea.yaml |
e.g. git.example.com |
<YOUR_DB_PASSWORD> |
apps/gitea/install/secrets.yaml, apps/gitea/install/gitea.yaml |
A strong password for PostgreSQL |
# Quick way to find all placeholders
grep -r '<YOUR_' --include='*.yaml' .
Why is a Cloudflare API token needed? Cert-manager uses DNS-01 challenges to prove domain ownership to Let's Encrypt. Unlike HTTP-01 challenges, DNS-01 doesn't require your server to be publicly reachable — ideal for homelabs behind NAT. It works by automatically creating a DNS TXT record on your domain, which requires API access to your DNS provider. Create a Cloudflare API token with Zone > DNS > Edit permission for your domain at dash.cloudflare.com/profile/api-tokens.
3. Set up secret encryption (SOPS)
Secrets are stored in dedicated secrets.yaml files and must be encrypted before committing to Git. This starter uses SOPS with age encryption so that Flux can decrypt them in-cluster while they stay encrypted at rest in your repository.
Generate an age key pair
age-keygen -o age.agekey
# Output: Public key: age1xxxxxxxxx...
Important: Add
age.agekeyto.gitignore— never commit your private key.
Update .sops.yaml
Replace <YOUR_AGE_PUBLIC_KEY> in .sops.yaml with the public key from the previous step. This file tells SOPS which files to encrypt and which YAML fields to target:
creation_rules:
- path_regex: infrastructure/.*/secrets\.yaml$
age: age1xxxxxxxxx... # your public key
encrypted_regex: "^(data|stringData)$"
- path_regex: apps/.*/secrets\.yaml$
age: age1xxxxxxxxx... # your public key
encrypted_regex: "^(data|stringData)$"
Only data and stringData fields are encrypted — metadata stays readable so Git diffs remain useful.
Encrypt your secret files
After filling in your actual values (step 2), encrypt each secrets file:
sops --encrypt --in-place infrastructure/cert-manager-issuer/secrets.yaml
sops --encrypt --in-place apps/gitea/install/secrets.yaml
To edit an encrypted file later:
sops infrastructure/cert-manager-issuer/secrets.yaml
Load the private key into the cluster
Flux needs the age private key to decrypt secrets during reconciliation:
kubectl create namespace flux-system
kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey=age.agekey
The Flux Kustomizations in this starter already reference this secret via decryption.secretRef.name: sops-age.
4. Bootstrap Flux
flux bootstrap git \
--url=<YOUR_GIT_REPO_URL> \
--branch=main \
--path=bootstrap
This command:
- Installs Flux components into the
flux-systemnamespace - Creates
bootstrap/flux-system/with auto-generated Flux manifests - Commits and pushes these files to your repo
- Starts the reconciliation loop
5. Watch Flux reconcile
# Watch all kustomizations converge
flux get kustomizations --watch
# Check pod status across all namespaces
kubectl get pods -A
6. Access Gitea
Once everything is reconciled and the TLS certificate is issued:
# Check the certificate status
kubectl get certificate -n gitea
# Open Gitea in your browser
echo "https://<YOUR_DOMAIN>"
Directory Structure
.sops.yaml # SOPS encryption rules (which files, which fields)
bootstrap/ # Flux bootstrap layer
├── flux-system/ # Auto-generated by `flux bootstrap` (don't edit)
├── kustomization/ # Flux Kustomization CRs — tell Flux what to deploy
│ ├── infrastructure/ # One file per infrastructure component
│ └── apps/ # One directory per app
│ └── gitea/
├── ns/ # Namespace definitions
└── repositories/ # Helm chart repository sources
infrastructure/ # Core cluster services
├── metallb-install/ # MetalLB Helm release
├── metallb-config/ # MetalLB IP pool and L2 advertisement
├── traefik-install/ # Traefik Helm release + config overrides
├── cert-manager-install/ # cert-manager Helm release + config overrides
├── cert-manager-issuer/ # Let's Encrypt ClusterIssuer + DNS credentials
│ ├── issuer.yaml
│ └── secrets.yaml # encrypted with SOPS
└── routes/ # Traefik IngressRoutes (one per app)
apps/ # Application deployments
└── gitea/
└── install/ # Gitea + PostgreSQL manifests
├── gitea.yaml
├── postgresql.yaml
└── secrets.yaml # encrypted with SOPS
How Flux Works
Flux follows a reconciliation loop:
- Source Controller watches your Git repository for changes
- Kustomize Controller applies Kustomization CRs (in
bootstrap/kustomization/) - Helm Controller installs/upgrades Helm releases (MetalLB, Traefik, Cert-Manager)
- If you push a change to Git, Flux detects it and updates the cluster automatically
The dependsOn fields in the Flux Kustomizations ensure things install in the right order:
MetalLB Install → MetalLB Config → Traefik Install → Routes
Cert-Manager Install → Cert-Manager Issuer
Traefik Install → Gitea Install
Kustomizations that reference encrypted secrets include a decryption block pointing to the sops-age secret, so Flux can decrypt them transparently during apply.
Next Steps
- Add a backup system — Set up Restic + Rclone to back up your PVCs and databases to cloud storage.
- Add your second app — See
docs/adding-an-app.mdfor a step-by-step walkthrough.
Troubleshooting
Flux not reconciling?
flux logs --level=error
flux get sources git # Check if Flux can reach your repo
flux reconcile source git flux-system # Force a sync
MetalLB not assigning IPs?
kubectl get ipaddresspool -n infrastructure
kubectl get svc -n infrastructure # Check if Traefik has an EXTERNAL-IP
Traefik not routing?
kubectl get ingressroute -A # Check if routes exist
kubectl logs -n infrastructure -l app.kubernetes.io/name=traefik
TLS certificate not issuing?
kubectl get certificate -A # Check certificate status
kubectl get certificaterequest -A # Check pending requests
kubectl describe clusterissuer cert-issuer # Check issuer health
kubectl logs -n infrastructure -l app=cert-manager
SOPS decryption failing?
# Check if the sops-age secret exists
kubectl get secret sops-age -n flux-system
# Check Flux kustomization status for decryption errors
flux get kustomizations
kubectl describe kustomization cert-manager-issuer--infra -n flux-system
Gitea not starting?
kubectl get pods -n gitea # Check pod status
kubectl logs -n gitea -l app=gitea # Check Gitea logs
kubectl logs -n gitea -l app=postgresql # Check DB logs