# Self-hosted services 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. ## What you get - 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](APPLICATIONS.md) for the complete list and service-specific setup steps. ## Prerequisites - Ubuntu 26.04 LTS hosts - A domain managed by Cloudflare - Ansible access to every host ## Quick start 1. [Configure Cloudflare DNS](#configure-cloudflare-dns). 2. Optionally configure [SES SMTP](#configure-ses-smtp), [ZFS](#configure-zfs), and [Tailscale](#configure-tailscale). 3. Create an inventory from the example below. 4. Complete the required entries in [the application catalog](APPLICATIONS.md#services-that-need-manual-setup). 5. [Deploy](#deploy) and open `https://dash.`. ## Configure Cloudflare DNS Cloudflare provides public DNS for the services. Its proxy can also shield eligible HTTP services from direct traffic. 1. Buy a domain name and [add it to Cloudflare](https://developers.cloudflare.com/fundamentals/manage-domains/add-site/). 2. Set SSL/TLS encryption mode to `Full (strict)`. 3. Add an `A` record for `@` that points to your public IP. 4. Add a wildcard `CNAME` record (`*`) that points to `@`. 5. Optionally enable Cloudflare's proxy for records that carry HTTP traffic. 6. Create an API token under `User Profile > API Tokens` with: - Permissions: - `Zone - DNS - Edit` - `Zone - Zone - Read` - Zone resources: - `Include - All Zones` Store the token in the [Cloudflare Kubernetes secret](charts/services/templates/cert-manager.yaml). ## Configure SES SMTP (optional, recommended) AWS SES provides outbound email for applications in this stack. 1. Create an AWS account and verify your domain in `AWS SES Console > Verified identities`. 2. Request production access from `AWS SES Console > Account dashboard` to leave sandbox mode. 3. Create SMTP credentials under `AWS SES Console > SMTP settings`, then store them in the [SMTP Kubernetes secret](charts/services/templates/smtp-secret.yaml). 4. Configure applications with the endpoint `email-smtp..amazonaws.com` and port 587 (STARTTLS) or 465 (TLS). ## Configure ZFS (optional, recommended) 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](#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/` directory. ## Configure Tailscale (optional) Tailscale provides remote access to hosts without exposing static ports. 1. Create an account at [Tailscale](https://login.tailscale.com). 2. Add this ACL fragment in the [ACL editor](https://login.tailscale.com/admin/acls/file): ```jsonc "tagOwners": { "tag:ansible": ["autogroup:admin", "autogroup:owner"], }, "autoApprovers": { "routes": { "192.168.0.0/16": ["tag:ansible"] }, }, ``` 3. Create an [OAuth client](https://login.tailscale.com/admin/settings/oauth). Enable **Write** for **Device/Core** and **Keys/Auth Keys**, and add the `tag:ansible` tag to each permission. ### Intranet access through Tailscale Use NextDNS to resolve your domain to the internal HAProxy virtual IP while remote devices are on Tailscale. 1. In [NextDNS](https://nextdns.io), add a rewrite under **Settings > Rewrites** for `*.example.org` that returns the HAProxy virtual IP, for example `192.168.1.2`. 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 Create an inventory file such as `inventory.yml` and adapt this example to your hosts: ```yml 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 speed ``` Use 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. ### Load balancing (optional) 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: ```bash ansible-playbook setup_router.yml -i inventory.yml ``` All inbound HTTP(S) traffic then passes through HAProxy on gateway ports 9080 and 9443. To skip this setup, set `chartValuesOverrides.behindTcpProxy` to `false`. ### QoS (optional) 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: ```yml routers: hosts: gateway: qos: download_kbps: 95000 # 95% of your download speed in kbps upload_kbps: 19000 # 95% of your upload speed in kbps ``` Run the router setup playbook to apply the configuration. ## Deploy Complete the applicable [manual application setup](APPLICATIONS.md#services-that-need-manual-setup), then run: ```bash ansible-playbook setup_cluster.yml -i inventory_static.yml -i inventory_ec2.yml ``` After deployment, disable Tailscale key expiry on every machine to avoid an unexpected loss of access. See [Tailscale's instructions](https://tailscale.com/kb/1028/key-expiry#disabling-key-expiry). Open `https://dash.` to access the service dashboard. ## Backup and recovery The PostgreSQL and Velero recovery procedures, including point-in-time recovery, are documented in [the application catalog](APPLICATIONS.md#backup-and-disaster-recovery). ## Configure self-hosted Renovate 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 and install the GitHub App 1. Create a GitHub App for this repository. It does not need a webhook URL or webhook events. 2. Give it the permissions listed in the [Renovate GitHub App guide](https://docs.renovatebot.com/modules/platform/github/#running-as-a-github-app). 3. Install the app on this repository only. 4. Copy the App ID from its settings. Get the installation ID from the installation settings page or `GET /repos/{owner}/{repo}/installation`. 5. Create a private key for the app. Keep the PEM file private. 6. Add these repository secrets in Woodpecker and scope them to `ghcr.io/yyewolf/woodpecker-plugins/github-app-token` plugin: - `archanes_github_app_id`: the App ID - `archanes_github_app_installation_id`: the installation ID - `archanes_github_app_private_key`: the PEM private key ### Configure the GitHub issue trigger 1. Add the `WOODPECKER_URL` and `WOODPECKER_REPO_ID` repository variables in GitHub. 2. Add `WOODPECKER_TOKEN` as a GitHub repository secret. The token must be able to start pipelines in Woodpecker. 3. Put `.github/workflows/trigger-woodpecker.yml` on the repository's default branch. [GitHub requires this for issue-triggered workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows). 4. Edit the open Dependency Dashboard issue. The workflow starts a Woodpecker pipeline after the issue body has stayed unchanged for 60 seconds.