The ranch cluster provides a set of shared services so that each app doesn't have to bring its own: HTTPS certificates, DNS registration, ingress, outbound email, storage, databases and secrets. This page explains how an app plugs into each of them. The services themselves are built by the k3s repo in Gitea, and the Ranch Kubernetes Cluster page describes how the cluster is put together.
In short
- Give each app its own namespace, named after the product (
media,documentation). Never use the-systemsuffix.- Expose apps through a Traefik Ingress with a
tlsblock and three annotations. Hostnames are always<name>.rachelski.internal.- Send email through
postfix-relay.mail-system.svc.cluster.localon port 25, with no credentials.- Databases use the CNPG or MariaDB operator on
longhorn-database. Other app data useslonghorn; shared or large files usenfs-nas.- Credentials live in Vault and reach pods through External Secrets.
Name an app's namespace after the product or workload group, for example media or documentation.
The -system suffix is reserved for the platform's own namespaces, which the k3s repo manages (metallb-system, longhorn-system, cnpg-system, mail-system and so on). App namespaces must not use it.
One Ingress resource does three jobs. Each job is handled by a different cluster service, which is why the hostname appears several times:
| Service | What it does | What triggers it |
|---|---|---|
| Traefik | Routes requests for the hostname to your Service | ingressClassName: traefik and rules[].host |
| cert-manager | Issues and renews the HTTPS certificate from the internal CA | The cluster-issuer and common-name annotations, and the tls block |
| external-dns | Registers the hostname in the internal DNS (Technitium) | The external-dns.alpha.kubernetes.io/hostname annotation |
A minimal working Ingress:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp
namespace: myapp
annotations:
cert-manager.io/cluster-issuer: ranch-cert-issuer
cert-manager.io/common-name: myapp.rachelski.internal
external-dns.alpha.kubernetes.io/hostname: myapp.rachelski.internal
spec:
ingressClassName: traefik
rules:
- host: myapp.rachelski.internal
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp
port:
number: 80
tls:
- hosts:
- myapp.rachelski.internal
secretName: myapp-tls
For a Helm chart, put the same annotations, ingressClassName, host and tls values into the chart's ingress settings.
ingressClassName: traefik. Traefik is the default, but set it explicitly anyway. Don't use the old kubernetes.io/ingress.class annotation or any nginx.ingress.kubernetes.io/* annotation. ingress-nginx was retired on 2026-10-04 and its nginx IngressClass no longer exists, so an Ingress that names it isn't served.cert-manager.io/common-name annotation, matching the host. The issuer is backed by Vault, and Vault needs an explicit common name. Without it the certificate request can be rejected or issued with the wrong name.rachelski.internal. The issuer refuses any other domain. See rachelski.internal PKI for how the certificates are issued.| Setting | Value |
|---|---|
| Ingress address | 192.168.200.230 (all *.rachelski.internal app names point here) |
| HTTPS | Forced for every Ingress. HTTP requests get a 301 redirect, so every Ingress needs a tls block. No redirect annotation is needed. |
| Uploads | No request size limit and no total read timeout. No annotation is needed for large uploads. |
| Client address | Requests that come in from the internet through Caddy on net-1 keep the real client IP and the public *.rachelski.net host in the X-Forwarded-For, -Proto and -Host headers. |
| Unknown hostnames | A hostname that no Ingress claims gets a generic *.rachelski.internal wildcard certificate and a plain-text Not found. If you see that for your app's name, Traefik isn't matching your Ingress: check ingressClassName and rules[].host. See Requests for unknown hostnames. |
Use the other app's Service name, http://<service>.<namespace>.svc.cluster.local, not its Ingress hostname. Calling a *.rachelski.internal name from a pod means HTTPS (because of the forced redirect), and the pod then has to trust the internal root CA. See Using Trust Manager if you really do need that.
App developers can't do this, and that's deliberate. Every app stays inside the home network. Only the person who manages the Caddy reverse proxy on net-1 can publish a service on the internet. See the Homelab Overview and the Rachelski Home Network pages.
Apps reach the network through the Ingress, not through their own LoadBalancer Services. MetalLB has a few fixed single-address pools, each limited to one namespace:
| Pool | Address | Namespace | Purpose |
|---|---|---|---|
ingress-pool |
192.168.200.230 |
traefik-system |
The Traefik ingress address |
torrent-pool |
192.168.200.231 |
media |
Router port-forward target for qBittorrent peer traffic (TCP and UDP 47613). Not assigned automatically: request it with the metallb.io/loadBalancerIPs annotation. |
Anything else that needs its own address needs a new pool, added in the k3s repo (bootstrap/network/metallb.tf).
All outbound email goes through the cluster's postfix relay. The relay holds the Mailjet credentials, so apps never need them.
| Setting | Value |
|---|---|
| Host | postfix-relay.mail-system.svc.cluster.local |
| Port | 25 |
| Authentication | None. Every pod in the cluster is allowed to send. |
| From address | webmaster@rachelski.net (the relay rewrites the sender) |
Don't put Mailjet's host (in-v3.mailjet.com) or any Mailjet key in an app's settings. If you find an app that does, point it at the relay instead.
Pick a storage class by how the data is used:
| Class | What it is | Use it for |
|---|---|---|
longhorn (default) |
Longhorn block storage, 3 replicas | General app data that needs to survive a node failure |
longhorn-database |
Longhorn, 1 replica, kept on the same node as the pod | All databases: PostgreSQL, MariaDB or anything else |
nfs-nas |
A folder on the NAS (diskstation.rachelski.internal:/volume3/homelab/k3s) |
Shared (ReadWriteMany) volumes, large media, archives |
none |
No provisioner | Binding a volume that already exists, by hand |
Don't create a private storage class for an app. If you find one with numberOfReplicas: 1 and dataLocality: strict-local, switch it to longhorn-database and remove the private class.
Longhorn backs volumes up to the NAS at nfs://192.168.200.155:/volume3/ranch-backups/backups.
Two snapshot classes are available, for example for database operator backups:
| Snapshot class | What it does |
|---|---|
longhorn |
A local snapshot inside the cluster only. Good for a quick rollback, useless if the cluster is lost. |
longhorn-backup |
Pushes the snapshot to the NAS backup target. Use this one for anything that needs real disaster recovery. |
longhorn-backup deletes the NAS copy when its VolumeSnapshot (or the Backup that owns it) is deleted. Treat those objects as carefully as the backups themselves.
longhorn-databaseA longhorn-database volume lives on the same node as its pod, to keep disk access off the network. That only works if the pod runs on one of the storage nodes, so every database pod needs both of these:
nodeSelector:
tier: pv-storage
tolerations:
- key: dedicated-purpose
operator: Equal
value: storage
effect: NoSchedule
Pods that use ordinary longhorn volumes don't need either. See Taints & Tolerations for the details.
longhorn-database volumes are kept when their claim is deleted: the volume moves to Released and the data stays. Database volume claims in Terraform should also carry lifecycle { prevent_destroy = true }. To retire a database volume on purpose:
prevent_destroy = true from the claim in Terraform, and commit that as a clear sign of intent.terraform apply. The claim is deleted and the volume moves to Released.kubectl delete pv.These have their own pages:
Use one of the database operators rather than a standalone database container, a StatefulSet you write yourself, or the database bundled in an app's Helm chart.