Built for homelab operators who want a complete, opinionated platform they can understand and control.
This repository uses Ansible to deploy Kubernetes and a private suite of services for media, photos, files, home automation, monitoring, backups, and local AI. Applications share PostgreSQL, Redis, MinIO, Authentik, SMTP, observability, and VPN-routed egress where they support them.
- Media and requests: Jellyfin, Jellyseerr, the Arr stack, and Transmission.
- Photos, files, and archives: Immich, Nextcloud, MinIO, Filebrowser, and ArchiveBox.
- Home and automation: Home Assistant, n8n, Mosquitto, and 3D-printing tools.
- Platform operations: Authentik SSO, Argo CD, PostgreSQL, Redis, Velero, Grafana, Loki, and Tempo.
- Local AI: llama.cpp and Open WebUI.
See the application catalog for the complete list and service-specific setup steps.
- Ubuntu 26.04 LTS hosts
- A domain managed by Cloudflare
- Ansible access to every host
- Configure Cloudflare DNS.
- Optionally configure SES SMTP, ZFS, and Tailscale.
- Create an inventory from the example below.
- Complete the required entries in the application catalog.
- Deploy and open
https://dash.<your-domain>.
Cloudflare provides public DNS for the services. Its proxy can also shield eligible HTTP services from direct traffic.
-
Buy a domain name and add it to Cloudflare.
-
Set SSL/TLS encryption mode to
Full (strict). -
Add an
Arecord for@that points to your public IP. -
Add a wildcard
CNAMErecord (*) that points to@. -
Optionally enable Cloudflare's proxy for records that carry HTTP traffic.
-
Create an API token under
User Profile > API Tokenswith:- Permissions:
Zone - DNS - EditZone - Zone - Read
- Zone resources:
Include - All Zones
Store the token in the Cloudflare Kubernetes secret.
- Permissions:
AWS SES provides outbound email for applications in this stack.
- Create an AWS account and verify your domain in
AWS SES Console > Verified identities. - Request production access from
AWS SES Console > Account dashboardto leave sandbox mode. - Create SMTP credentials under
AWS SES Console > SMTP settings, then store them in the SMTP Kubernetes secret. - Configure applications with the endpoint
email-smtp.<region>.amazonaws.comand port 587 (STARTTLS) or 465 (TLS).
ZFS adds checksumming, compression, and reliable storage management. Put the
zfs object directly under each storage-capable host in your Ansible inventory,
as shown in Create an inventory. Not every Kubernetes
node necessarily has disks intended for a ZFS pool. Use whole-disk, stable
/dev/disk/by-id, /dev/disk/by-partuuid, or /dev/disk/by-uuid paths; the
role rejects /dev/sdX, /dev/nvme*, and every other unstable device path.
The role creates cluster-local-storage (128K recordsize) and multimedia
(1M recordsize) beneath the encrypted dataset. Any additional dataset must
declare a recordsize.
Existing pools are never recreated or destroyed. Existing vdevs must exactly match the declared disk set and topology; otherwise the run fails before it can change the pool. Declared vdevs that are absent are added. A disk already owned by another pool is rejected. Existing encrypted datasets must retain their configured key location; an existing key at that location is reused. Back up each host's keyfile before relying on encrypted storage.
Set backup_keys_to_controller: true under inventory all.vars to back up
generated or reused keys to the Ansible controller. They are stored in the
gitignored keys_backup/<inventory> directory.
Tailscale provides remote access to hosts without exposing static ports.
-
Create an account at Tailscale.
-
Add this ACL fragment in the ACL editor:
-
Create an OAuth client. Enable Write for Device/Core and Keys/Auth Keys, and add the
tag:ansibletag to each permission.
Use NextDNS to resolve your domain to the internal HAProxy virtual IP while remote devices are on Tailscale.
- In NextDNS, add a rewrite under Settings > Rewrites for
*.example.orgthat returns the HAProxy virtual IP, for example192.168.1.2. - In the Tailscale admin console under DNS, add the IPv6 resolver address from NextDNS as a global nameserver and enable Override local DNS.
Create an inventory file such as inventory.yml and adapt this example to your hosts:
all:
vars:
k3s_control_node: false
skip_system_setup: false
skip_zfs_setup: false
skip_firewall_setup: false
skip_vpn_setup: false
skip_k8s_setup: false
skip_binary_update: false
manifest_only_setup: false
backup_keys_to_controller: false
timezone: America/Vancouver
tenants_count: 1
tailscale_oauth_secret: "some_secret"
chartValuesOverrides:
fqdn: "example.com"
storageLocation: /storage
# Optional: disable high availability (reduces service replicas from 3 to 1)
highAvailability: false
# Optional: disable unwanted applications
applications:
crowdsec:
enabled: false
# There must be a minimum of 3 controllers and the number must be odd for etcd to work
k3s_cluster:
hosts:
raspi:
k3s_control_node: true
labels:
- local=true
big_manager:
k3s_control_node: true
labels:
- local=true
- nas=true
zfs:
pools:
- name: storage
vdevs:
- type: mirror
disks:
- /dev/disk/by-id/wwn-0x5000...
- /dev/disk/by-id/wwn-0x5000...
# Optional; defaults to /root/pool_name-keyfile-zfs
encryption_key_location: /root/storage-keyfile-zfs
# Optional; defaults to /storage
mountpoint: /storage
datasets:
- name: backups
recordsize: 1M
# Optional; snapshots are enabled by default.
snapshot: true
small_manager:
k3s_control_node: true
labels:
- local=true
- public=true
big_server:
labels:
- local=true
- runner=true
# Optional: advertise this host as a Tailscale exit node.
# tailscale_exit_node: true
# Optional
headscale:
hosts:
headscale_control_server: {}
# Optional
routers:
hosts:
gateway:
# wan_interface: "eth1" # Optional, will be auto-detected if not set
# lan_ip: "192.168.1.1" # Optional, will be auto-detected from br_lan interface if not set
haproxy:
# virtual_ip: 192.168.1.2 # Optional, will be deduced from lan_ip if not set
servers:
s1: 192.168.1.11
s2: 192.168.1.12
s3: 192.168.1.13
# (optional, recommended) QoS config
qos:
download_kbps: 95000 # 95% of your download speed
upload_kbps: 19000 # 95% of your upload speedUse an odd number of controller nodes. Add public to nodes that receive external traffic, nas to nodes that store large files, and local to nodes at the site. Nodes dedicated to AI workloads need the dedicated=ai label and dedicated=ai:NoSchedule taint.
On an OpenWrt-capable router, such as the GL-MT6000, HAProxy can distribute inbound traffic across the cluster. Add the router to the routers inventory group, then run:
ansible-playbook setup_router.yml -i inventory.ymlAll inbound HTTP(S) traffic then passes through HAProxy on gateway ports 9080 and 9443. To skip this setup, set chartValuesOverrides.behindTcpProxy to false.
Smart Queue Management (SQM) reduces bufferbloat during heavy uploads and downloads. Measure your connection speed, then add 95% of those values to the router configuration:
routers:
hosts:
gateway:
qos:
download_kbps: 95000 # 95% of your download speed in kbps
upload_kbps: 19000 # 95% of your upload speed in kbpsRun the router setup playbook to apply the configuration.
Router setup validates the existing firewall before changing settings and keeps a
private temporary backup on the router. If a configuration task or firewall
validation fails, it restores /etc/config/firewall for the next boot and stops
without a recovery restart. Other settings are not rolled back. Loss of SSH or
power can prevent recovery; this is not a substitute for console access or a
firmware backup. Pending UCI firewall edits must be committed or reverted first.
On Qualcomm NSS firmware (including the Flint 3), leave
qos_force_flow_offloading unset or false. Router setup rejects enabling kernel
flow offloading on detected NSS hardware. The MediaTek GRO workaround is restricted
to the mtk_soc_eth driver; VPN UDP offloading requires kernel 6.2 or newer.
The local regression tests use temporary files and fake firewall commands:
python -m unittest discover -s tests -p 'test_router_safety.py' -v
(run with Python from the Ansible environment).
Complete the applicable manual application setup, then run:
ansible-playbook setup_cluster.yml -i inventory_static.yml -i inventory_ec2.ymlAfter deployment, disable Tailscale key expiry on every machine to avoid an unexpected loss of access. See Tailscale's instructions.
Open https://dash.<your-domain> to access the service dashboard.
The PostgreSQL and Velero recovery procedures, including point-in-time recovery, are documented in the application catalog.
Woodpecker runs Renovate on its hourly schedule and when a person edits the body of the open Dependency Dashboard issue. The GitHub Actions job waits for 60 seconds without another body edit before it starts Woodpecker. A newer body edit cancels the waiting job and restarts the wait. The job skips edits from the Renovate app, so those edits do not start another Woodpecker run.
- Create a GitHub App for this repository. It does not need a webhook URL or webhook events.
- Give it the permissions listed in the Renovate GitHub App guide.
- Install the app on this repository only.
- Copy the App ID from its settings. Get the installation ID from the installation settings page or
GET /repos/{owner}/{repo}/installation. - Create a private key for the app. Keep the PEM file private.
- Add these repository secrets in Woodpecker and scope them to
ghcr.io/yyewolf/woodpecker-plugins/github-app-tokenplugin:renovate_github_app_id: the App IDrenovate_github_app_installation_id: the installation IDrenovate_github_app_private_key: the PEM private key
- Add the
WOODPECKER_URLandWOODPECKER_REPO_IDrepository variables in GitHub. - Add
WOODPECKER_TOKENas a GitHub repository secret. The token must be able to start pipelines in Woodpecker. - Put
.github/workflows/trigger-woodpecker.ymlon the repository's default branch. GitHub requires this for issue-triggered workflows. - Edit the open Dependency Dashboard issue. The workflow starts a Woodpecker pipeline after the issue body has stayed unchanged for 60 seconds.