Files
homelab-starter/README.md
2026-02-14 10:46:57 -05:00

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
  • kubectl configured 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.agekey to .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-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

# 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:

  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 — Set up Restic + Rclone to back up your PVCs and databases to cloud storage.
  • Add your second app — See docs/adding-an-app.md for 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