k8s-management — Ansible configuration

Ansible project that configures the k8s-management Proxmox LXC container (ID 105) over SSH:

RoleWhat it does
ssh_serverInstalls/enables openssh-server, creates the jan login (member of sudo, passwordless sudo), installs the control-node public key for jan and root, and hardens sshd (key-only logins, root restricted to keys) through /etc/ssh/sshd_config.d/99-k8s-management.conf.
toolsInstalls the base CLI packages plus talosctl, kubectl and helm (latest stable by default) and enables kubectl/helm bash completion.
talos_clientWrites the managed talosconfig (/root/.talos/config) for the control plane: the client certs come from the OpenTofu talosconfig output, and the Talos control-plane endpoint/node are pinned with talosctl config endpoint / config node.
kubeconfigGenerates the admin kubeconfig (/root/.kube/config) with talosctl kubeconfig and points its server at the reachable control plane, so kubectl works without arguments.
local_path_storageInstalls Rancher local-path-provisioner and the local-path StorageClass (dynamic 10Gi-style PVCs on the single node), pointed at /var/local-path-provisioner.

Layout

a├├├├├├├├├├├├├├││││├├│└n─────────────────s─────────────────ibahrsfffaanttgf└ft└r├││││├││├││├││└lnoeiooorrgrrri─ia─o─────essqtrrrggiaaol─ls─l─────/ituegggooneeueekebsi.eeeccxffpsa├└ssfss├├├└t├└t├└k├└l├└l.ryjjjdd-ii_/p──//o/s────o──a──u──o──eyemooo.-dkkgp──trh────o──l──b──c──.mml.--yfe..aisrg_loeacleypcmomymrtdn└aesdhttsdtsdtcdtldtfnmlolromdseg─ejeeaae/ea_eaoea_eagtlang-lpmi─forfnsmfscfsnfspfssyteaasoni_vadkpaklakfakaak.brjrl/.xakfeulslusiusiustusyooogly-p-irle/al/el/gl/hl/mol.o.adpvl/trmttmntm/tm_tmlkemcyme.aessaesatsasassa-.ddmlmyl.//is/i//i/itiwm.loauymmnmnmnmnmnidm/memaa.sa.a.a.ra.jdlsliiysiyiyiyaiyz.nnmhnmnmnmgnmiy..ld.l.l.le.lgayy_yyyyimmmcmmmmnlllollll#####g#########ne#fciappncphnpiumzionnll.ologlnsaHegnvsaamnaeiagenet.teiyydtynyrrildrnbbbrbAxbesfmé/otloooor-os,e-é9loeoo#logdosswn9-r.kkekoek,kta-nyp::ce:Cm:esabko:ohrDo/yre8dssFaATesddssetisonorFvrt,iet-hxhrgfgoiacenamce_georae/snaocselFCgfhsidnncoejooDeAiohnafolrogrjrksdoiginlvgmogtdrdegteeoveeois+eemacrnajtgCn,Tme(itnoeDgtFrpnini+KFbrvooaotneouddor(eeoret.vrntberruGsrlgfece'oeagiisieinonsorFaektfsjktntlnoijtO+ieofohsertopct-i.rotg+sbatrnjym+eea)rtie2,esjlciinpF-toso+degooknav-ngsreeliwGtceewgytoaiironoe,wsjtonorjo_Hz-ltpbdobrceibereeekllgrodhncimioleeoaenneegmdn(gmreedtieaz)rnnnee+catslgskueudmbeeesnctotcnoofoniltgsa)iner

Prerequisites

Usage

cd ansible
ansible-galaxy collection install -r requirements.yml   # once
ansible-playbook site.yml

Deploying Forgejo

forgejo.yml installs (or upgrades) Forgejo on the Talos cluster with Helm, into the forgejo namespace, with a 10Gi persistent volume:

ansible-playbook forgejo.yml

The chart is published as an OCI artifact (oci://code.forgejo.org/forgejo-helm/forgejo), so there is no helm repo add step. The play includes the tools role, so helm is installed on the container when it is missing — no separate site.yml run is required, though the admin kubeconfig (/root/.kube/config, created by site.yml) must exist.

The play also includes the local_path_storage role, which installs local-path-provisioner because the cluster ships without a StorageClass. Two environment-specific details are handled there:

Volume data lives in /var/local-path-provisioner on the node’s Talos EPHEMERAL partition: it survives reboots but not a Talos upgrade or reinstall. To keep repositories and the SQLite database on the data disk, mount scsi1 at /var/mnt/storage and set local_path_provisioner_data_dir: /var/mnt/storage/local-path-provisioner.

Override the chart version or volume size per run:

ansible-playbook forgejo.yml \
  -e forgejo_chart_version=17.3.1 -e forgejo_storage_size=20Gi

Deploying ArgoCD with Forgejo

argocd.yml installs (or upgrades) ArgoCD with Helm into the argocd namespace and wires it to Forgejo as its Git source:

ansible-playbook argocd.yml

What it sets up:

The play reuses the chart-managed admin credentials (forgejo-admin secret); repo and file writes use that admin login, while ArgoCD only gets read access. forgejo.yml must have run first. The play refreshes both Applications and waits until they are on the latest commit.

The UI is ClusterIP-only; use a port-forward:

ssh root@192.168.0.137 \
  'kubectl -n argocd port-forward --address 0.0.0.0 svc/argocd-server 8080:443'
# https://192.168.0.137:8080 — user admin
ssh root@192.168.0.137 \
  "kubectl -n argocd get secret argocd-initial-admin-secret \
   -o jsonpath='{.data.password}' | base64 -d; echo"

The nginx demo is exposed through Traefik, not a kubectl port-forward; the Ingress in apps/nginx-demo/app.yaml routes its hostname to the ClusterIP Service. See Deploying the Traefik ingress below for the install command and the /etc/hosts entry.

See argocd-forgejo.md for the ArgoCD/Forgejo setup and nginx-demo-argocd.md for the nginx demo, the GitOps change flow and troubleshooting.

Deploying the Traefik ingress

traefik.yml installs (or upgrades) Traefik with the OCI chart oci://ghcr.io/traefik/helm/traefik into the traefik namespace and makes it the default IngressClass:

ansible-playbook traefik.yml

It uses files/traefik-values.yaml for:

Because the Talos node is only on 10.10.50.0/24, the play also installs a small systemd-socket-proxyd bridge on k8s-management:

192.168.0.137:80  -> 10.10.50.22:30080
192.168.0.137:443 -> 10.10.50.22:30443

The bridge is L4 only; Traefik still performs all host-based routing. Add one /etc/hosts entry per hostname on the workstation, for example:

192.168.0.137  nginx-demo.kubernetes.local

traefik.yml must run after argocd.yml, because it checks that the nginx Ingress already exists. A second run is idempotent (only the Helm task reports changed). See traefik.md for the full design, /etc/hosts instructions for Linux/macOS/Windows, TLS options and troubleshooting.

Logging in from another server

The remote login name is always jan (the account that exists on the container). The client machine does not need a local jan account — it only needs a private key whose public key is authorized on the container:

Notes:

Inventory and the container address

hosts.yml points at the container’s DHCP address on the home network (vmbr0). This is deliberately not 10.10.50.10: that address lives on the isolated vmbr1 bridge and is only reachable from inside the Talos subnet.

If the DHCP lease changes, override it for a run:

ansible-playbook site.yml \
  -e "ansible_host=$(cd .. && tofu output -json management | jq -r .wan_ips.eth0)"

Variables

All defaults live in group_vars/all.yml:

VariableDefaultPurpose
admin_userjanLogin created on the container.
admin_groups[sudo]Supplementary groups for the login.
ssh_public_key_path~/.ssh/id_ed25519.pubKey installed for jan and root.
ssh_extra_authorized_keys[]Extra public keys authorized for jan (other servers/operators).
ssh_authorized_keys_exclusivefalseWhen true, remove any keys not listed above.
sshd_password_authenticationfalseInteractive password logins.
sshd_permit_root_loginprohibit-passwordRoot may log in with a key only.
sshd_allow_usersroot janAccounts sshd accepts.
talosctl_version"" (latest)Pin a talosctl release.
kubectl_version"" (stable)Pin a kubectl release.
helm_version"" (latest)Pin a Helm release.
tools_packagessee fileBase CLI packages.
talos_endpoint_ip10.10.50.22Talos control-plane endpoint written to the talosconfig (also used as the default node).
talos_node_ip{{ talos_endpoint_ip }}Node(s) bare talosctl commands target.
talos_config_file/root/.talos/configPath of the managed talosconfig.
talos_tofu_dir{{ playbook_dir }}/..OpenTofu project providing the talosconfig output (client certs).
kube_config_dir/root/.kubeDirectory of the managed admin kubeconfig.
kubeconfig_file{{ kube_config_dir }}/configPath of the managed kubeconfig.
kube_api_endpoint{{ talos_endpoint_ip }}Control plane host written into the kubeconfig (:6443).

Verification

The playbook finishes by asserting the effective sshd configuration is key-only, asserting the talosconfig targets the Talos control plane, asserting the kubeconfig points at the control plane, probing both APIs (non-fatal) and reporting the installed versions. You can also check by hand:

ssh jan@<container> 'whoami; id; sudo -n whoami'
ssh jan@<container> 'talosctl version --client --short; kubectl version --client; helm version --short'
ssh root@<container> 'talosctl config info'        # endpoint/node of the control plane
ssh root@<container> 'kubectl config view --minify -o jsonpath="{.clusters[0].cluster.server}"'

ArgoCD

ssh root@<container> 'kubectl -n argocd get application root \
  -o jsonpath="{.status.sync.status}/{.status.health.status}"; echo'
ssh root@<container> 'kubectl -n argocd get application nginx-demo \
  -o jsonpath="{.status.sync.status}/{.status.health.status}"; echo'
ssh root@<container> 'kubectl -n argocd-demo get configmap argocd-demo'
ssh root@<container> 'kubectl -n nginx-demo get deploy,pods,svc'
ssh root@<container> 'kubectl -n argocd get secret repo-forgejo'
ssh root@<container> 'kubectl get ingressclass'
ssh root@<container> 'kubectl -n nginx-demo get ingress nginx'

Expected: both Applications Synced/Healthy, the demo ConfigMap managed by ArgoCD, the nginx pod 1/1 Running, IngressClass traefik, and Ingress nginx with host nginx-demo.kubernetes.local. See nginx-demo-argocd.md for the GitOps change flow and traefik.md for the ingress and /etc/hosts setup.

Set the control-plane endpoint with the same command the role uses:

ssh root@<container> '/usr/local/bin/talosctl config endpoint 10.10.50.22'

Note: the client certificate/key in the managed talosconfig come from the OpenTofu talosconfig output. If you generate a talosconfig by hand (talosctl gen config) it belongs to a different PKI and cannot talk to the cluster — re-run this playbook to restore the matching one.

Note: the OpenTofu node_ips[0] / cluster endpoint is 10.10.50.10, but that address is also the management container’s own eth1 (management_lan_ip), so it is not reachable as a Talos API from the container. The default talos_endpoint_ip therefore points at the reachable control plane 10.10.50.22 (talos-node-3); override it with -e talos_endpoint_ip=... once the IP conflict is resolved.

Relationship to the OpenTofu bootstrap

management.tf only bootstraps the container: it brings the interfaces up and seeds root’s authorized key so Ansible can connect. Everything else — the SSH server, the jan login and the tooling — is owned by this project, so the container’s boot script no longer installs anything.

The container entrypoint is written at creation time, so a change to the boot script only takes effect after a recreate:

tofu apply -refresh=false -auto-approve \
  -replace=proxmox_virtual_environment_container.management \
  -target=proxmox_virtual_environment_container.management

Security notes