# 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](https://k3s.io) installed - `kubectl` configured to talk to your cluster - [Flux CLI](https://fluxcd.io/flux/installation/) installed locally - [SOPS](https://github.com/getsops/sops) and [age](https://github.com/FiloSottile/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 ```bash # 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 git push -u origin main ``` ### 2. Replace placeholder values Search for all `` placeholders and replace them with your values: | Placeholder | File(s) | Description | |---|---|---| | `` | `infrastructure/metallb-config/ipaddresspool.yaml` | LAN IP range for LoadBalancer services, e.g. `192.168.1.200-192.168.1.210` | | `` | `infrastructure/traefik-install/traefik-override.yaml` | Your local network CIDR, e.g. `192.168.1.0/24` | | `` | `infrastructure/cert-manager-issuer/issuer.yaml` | Email for Let's Encrypt notifications | | `` | `infrastructure/cert-manager-issuer/secrets.yaml` | Cloudflare API token (see below) | | `` | `infrastructure/routes/gitea.yaml`, `apps/gitea/install/gitea.yaml` | e.g. `git.example.com` | | `` | `apps/gitea/install/secrets.yaml`, `apps/gitea/install/gitea.yaml` | A strong password for PostgreSQL | ```bash # Quick way to find all placeholders grep -r ' DNS > Edit** permission for your domain at [dash.cloudflare.com/profile/api-tokens](https://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](https://github.com/getsops/sops) with [age](https://github.com/FiloSottile/age) encryption so that Flux can decrypt them in-cluster while they stay encrypted at rest in your repository. #### Generate an age key pair ```bash age-keygen -o age.agekey # Output: Public key: age1xxxxxxxxx... ``` > **Important:** Add `age.agekey` to `.gitignore` — never commit your private key. #### Update `.sops.yaml` Replace `` 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: ```yaml 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: ```bash 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: ```bash 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: ```bash 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 ```bash flux bootstrap git \ --url= \ --branch=main \ --path=bootstrap ``` This command: - Installs Flux components into the `flux-system` namespace - 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 ```bash # 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: ```bash # Check the certificate status kubectl get certificate -n gitea # Open Gitea in your browser echo "https://" ``` ## 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**: 1. **Source Controller** watches your Git repository for changes 2. **Kustomize Controller** applies Kustomization CRs (in `bootstrap/kustomization/`) 3. **Helm Controller** installs/upgrades Helm releases (MetalLB, Traefik, Cert-Manager) 4. 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** — I have used Restic + Rclone to back up my data to OneDrive but there are other options. - **Add your second app** — See [`docs/adding-an-app.md`](docs/adding-an-app.md) for a step-by-step walkthrough. ## Troubleshooting **Flux not reconciling?** ```bash 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?** ```bash kubectl get ipaddresspool -n infrastructure kubectl get svc -n infrastructure # Check if Traefik has an EXTERNAL-IP ``` **Traefik not routing?** ```bash kubectl get ingressroute -A # Check if routes exist kubectl logs -n infrastructure -l app.kubernetes.io/name=traefik ``` **TLS certificate not issuing?** ```bash 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?** ```bash # 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?** ```bash 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 ```