This week I moved two related pieces of the homelab forward. The larger change was an infrastructure-as-code bootstrap for the DMZ Talos environment and a more explicit Argo CD ownership model for the infrastructure cluster. The smaller change was the public DNS and reverse-proxy path for the OpenCode web service running on the Hermes VM. They are different layers of the stack, but they follow the same operating rule: infrastructure should be declared once, have clear ownership, and expose only the narrow interface it needs.
The work landed in two repositories. The durfy/homelab/iac change merged the feature/infra-v2-argocd-bootstrap branch through commit 368aa6ae, following implementation commit e5da7a8c. The durfy/homelab/gitops change merged the OpenCode route through f7795603, with 97a45648 containing the actual manifest additions. The IAC pipeline for the merged bootstrap commit completed successfully. This roundup covers the implementation rather than treating merge commits as separate feature work, since each merge commit carries the same feature diff as its parent branch commit.
Reworking the DMZ Around Talos
The DMZ had older K3s-oriented VM definitions alongside the direction I want the homelab to take: Talos-managed Kubernetes clusters built from a common module. The important part of this change is not just creating six virtual machines. It makes the DMZ conform to the same cluster construction pattern as the other environments, so machine topology, bootstrap sequencing, credentials, DNS, and downstream GitOps registration are all represented in Terraform.
I declared three Talos control-plane nodes and three worker nodes for VLAN 98. The control-plane nodes are control01-dmz through control03-dmz, assigned addresses 192.168.98.14 through .16. The worker nodes are worker01-dmz through worker03-dmz, assigned .24 through .26. Each role is distributed across the Proxmox nodes mothership, overlord, and vanguard. That placement is intentional. A three-member control plane only earns its availability properties when it is not concentrated on one physical hypervisor, and spreading workers also prevents a single Proxmox host failure from eliminating all application capacity.
The control-plane shape is deliberately modest: two vCPUs, 4 GiB of memory, and a 20 GiB disk per node. Workers receive four vCPUs, 8 GiB of memory, and 120 GiB disks. Storage is explicitly set to cache-domains per role instead of relying on a generic storage local. This makes the resources a useful description of actual placement rather than an incomplete template that silently inherits a default. The nodes use the talos-Template Proxmox template, raw disk format, and the existing VLAN-derived DNS server and gateway at 192.168.98.1.
The cluster endpoint is https://kube.dmz.durp.loc:6443, with a virtual IP at 192.168.98.9. Naming both in the module input matters. Control-plane nodes can be replaced or restarted individually, while clients continue to use one stable endpoint. This is the difference between a collection of machine addresses and a cluster interface. The Talos module receives the complete control-plane and worker address lists, the cluster name dmz, the GitLab project ID used by the existing module flow, and an explicit dependency on the Proxmox VM modules. Terraform should not attempt Talos configuration before the machines that receive it exist.
I also made the scheduling policy explicit: workloads are not scheduled onto DMZ control-plane nodes. That preserves the control plane for Kubernetes itself and keeps application pressure from becoming an availability problem for etcd or the API server. It costs some schedulable capacity, but the cluster already has three dedicated workers and the boundary is easier to operate than a mixed role setup.
Separating the Old K3s Estate
Migration work tends to go wrong when new and old topology are allowed to compete for the same intended state. The old DMZ K3s master and server resources remain in the configuration, but their counts are set to zero. The same pattern is used for the older OpenVPN and Matrix VM definitions. The resources retain their historic definitions and use the old Debian template where relevant, while the new Talos resources use their own modules and template. This creates an explicit transition instead of a partial rewrite that obscures which system is meant to own a machine.
I also set prevent_destroy = false on the legacy K3s resources. This is not a general recommendation to leave destructive operations unconstrained. Here it aligns Terraform with the migration decision: those old resources have zero count and are intentionally eligible for removal as the Talos environment becomes the authoritative runtime. Keeping a hard destroy prevention flag on resources that the configuration has deliberately retired would turn a planned migration into manual state surgery.
The legacy resources were tightened up while they remain present. Their cloud-init and disk blocks now use their role-specific storage values rather than a generic local.storage, and their clone source points to templateOld. That prevents the old K3s declarations from accidentally following the Talos template change. It is a small but useful distinction. A migration is safer when the legacy path is frozen to its known inputs and the replacement is isolated behind new resources.
The Proxmox provider definition was moved into the DMZ-specific file with its inputs nearby. It retains serialized provider parallelism (pm_parallel = 1), disables the minimum permission check, and uses the existing API URL, user, and password variables. Serialized operations are sensible for this environment because VM creation is a control-plane operation against a real hypervisor, not a benchmark. Fewer concurrent provider actions means less ambiguity when a clone, cloud-init update, or IP configuration fails.
I updated the Proxmox provider version from 3.0.1-rc9 to 3.0.2-rc07 and made the broader provider inventory explicit: Talos, GitLab, Argo CD, UniFi, and Vault are all declared with pinned versions. That is an operational improvement even where a given provider is not the center of the change. The root module communicates the set of external APIs that Terraform will drive, and a pinned version prevents a fresh initialization from silently selecting a newer behavior.
Connecting the Cluster to Vault
A cluster bootstrap is incomplete if workloads cannot get their secrets or certificates through the normal path. The DMZ now has Terraform for the Vault Kubernetes auth backend and for the Kubernetes-side identities Vault needs to authenticate requests.
The Kubernetes provider is built from the kubeconfig emitted by the Talos module. Terraform decodes the kubeconfig and supplies the API server, CA certificate, client certificate, and client key to the provider. This avoids a separate hand-maintained kubeconfig for bootstrap resources. The state of the Talos cluster and the credentials Terraform uses to configure its initial Kubernetes resources come from the same generated artifact.
On the Kubernetes side, I create a vault-token-reviewer service account in kube-system and bind it to the built-in system:auth-delegator ClusterRole. Vault needs this ability to call the Kubernetes TokenReview API when validating pod service-account tokens. Since current Kubernetes versions do not create long-lived service-account token secrets automatically, Terraform also creates the explicitly annotated kubernetes.io/service-account-token Secret used as the reviewer JWT.
Vault receives a Kubernetes auth backend at the dmz-cluster path. Its configuration points at the Talos API server and CA extracted from the generated kubeconfig, and uses the token-reviewer JWT. Issuer validation is disabled in this configuration, which is a compatibility choice that should stay limited to the known cluster relationship rather than becoming a pattern for arbitrary auth mounts. The result is still scoped to the DMZ API endpoint and the reviewer identity created for it.
The external-secrets-role binds specifically to the external-secrets-dmz service account in the external-secrets namespace. It receives the external-secrets-policy, which grants read access under kv/*, and has a one-hour token TTL. This lets External Secrets fetch declared secret material without embedding Vault credentials into Git manifests. The broad kv/* policy is pragmatic for the initial environment bootstrap, but it is an obvious candidate to split by application or namespace as the DMZ gains more tenants.
Certificate issuance follows the same model. Terraform ensures the cert-manager namespace exists before creating the vault-issuer service account, then creates a Vault policy granting create and update access to pki/sign/issue-homelab-certs and pki/issue/*. The pki-issuer-role is bound to that exact service account and namespace, with a one-hour TTL and the newly created PKI policy. The service-account token Secret is created in the same namespace. Creating the namespace first is a small dependency correction, but it removes an ordering failure that otherwise appears only on a clean cluster.
Making Argo CD the Delivery Boundary
The infrastructure bootstrap also adds Argo CD registration for the DMZ and reinforces the application-of-applications model in the infrastructure environment. This is the point where provisioning ends and delivery begins. Terraform creates the substrate and the initial control-plane integration, while Argo CD continuously reconciles the Kubernetes applications described in the GitOps repository.
The DMZ Argo CD provider targets argocd.infra.durp.info:443. The argocd_cluster resource registers https://kube.dmz.durp.loc:6443 under the name dmz. Its TLS configuration is populated directly from the Talos-generated kubeconfig, including CA data and client credentials. That makes cluster registration reproducible. I do not need to run a separate imperative argocd cluster add command and then wonder how the registration was configured months later.
The registered cluster carries managed-by = terraform metadata. The environment label is currently dmzelopment, which is the literal configuration value in this week’s commit. Metadata labels are consumed by automation and people, so this is worth correcting in a later cleanup before other configuration starts depending on the typo. The important architectural point is already present: the cluster connection is declared alongside the infrastructure that creates it.
For the infrastructure cluster, Terraform installs Argo CD from the argo-cd Helm chart version 6.7.11 into the argocd namespace, creating the namespace when necessary. The release has an ingress host of argocd.infra.durp.loc. Version changes are ignored after deployment, which keeps Terraform from automatically changing the controller during unrelated plans. That tradeoff requires deliberate upgrade work, but it avoids a routine infrastructure apply becoming an accidental Argo CD upgrade.
Terraform also creates the GitLab repository definition secret for https://gitlab.durp.info/durfy/homelab/gitops.git and then creates the root Argo CD Application. The application points at infra/argocd on the main revision, targets the in-cluster Argo CD namespace, and enables automated pruning and self-healing. This is the handoff I want: Terraform owns the Argo CD installation and root object, while the GitOps repository owns the managed application graph below it. Automated prune makes removed resources converge away, and self-healing returns drifted live objects to the repository state.
The repository secret currently declares the URL and type only. That is enough for a publicly readable or otherwise accessible repository configuration, but any future private-repository requirement must add credentials through the intended secret mechanism rather than embedding them in the Application manifest. Keeping this boundary explicit is more maintainable than mixing repository access details into each workload definition.
DNS and Supporting Environment Work
The IAC change also adds UniFi DNS records for unraid.dmz.durp.loc at 192.168.98.200 and unraid.infra.durp.loc at 192.168.12.200, with 300-second TTLs. These records are small, but they make the environment naming scheme consistent: services can refer to stable names within their local network rather than hard-coded storage addresses. The UniFi provider configuration lives with its variables in the relevant environment directories.
The infrastructure workers were increased from 8 GiB to 16 GiB of memory each. That is a direct capacity decision for the existing infrastructure workload profile. It is better to express the increased headroom in Terraform than solve memory pressure later by changing hypervisor settings out of band. The older infrastructure K3s resources are also moved to zero count and made removable, matching the Talos migration approach used in DMZ.
I added generated kubeconfig.yaml files and the local .omo directory to .gitignore. Kubeconfigs are credential-bearing cluster access artifacts and should not become accidental repository content. Local tooling state is not infrastructure state, so ignoring .omo avoids noise in plans and commits. The production CI trigger remains present but commented out. That keeps the production path visibly deferred rather than deleting it and losing the reminder that it needs an intentional enablement decision.
Publishing OpenCode Through the DMZ
The GitOps portion of the week exposes the OpenCode web service at opencode.durp.info. The service itself runs on the Hermes VM at port 4096. I did not turn it into a Kubernetes workload just to make routing convenient. The right boundary here is the existing DMZ internal-proxy chart: Kubernetes and Traefik provide the controlled edge, while the service remains where it is operated today.
The internal-proxy Endpoints and Service definitions now include a named opencode port at 4096. Adding both the endpoint subset and Service port is required for Kubernetes to resolve the target consistently. The IngressRoute sends Host(opencode.durp.info) && PathPrefix(/) traffic on the websecure entry point to the existing hermes service on that port. This preserves the internal-proxy pattern instead of creating a one-off external Service or exposing the VM directly through a firewall rule.
The route includes the existing Traefik whitelist middleware from the traefik namespace. That is the security control for this service. OpenCode is an internal administration and agent interface, not a general public application, so IP allowlisting is appropriate and smaller than adding a new authentication layer solely for one service. The route still uses TLS. A cert-manager Certificate requests opencode-tls from the letsencrypt-production ClusterIssuer with opencode.durp.info as both common name and DNS name.
Finally, an ExternalName Service named opencode-external-dns carries the ExternalDNS hostname annotation for opencode.durp.info. It resolves through durp.info, following the existing proxy DNS convention. This makes DNS ownership declarative alongside ingress and certificate ownership. A future operator can trace the hostname from DNS annotation, to TLS certificate, to Traefik route, to the proxy endpoint, without relying on an undocumented record in a provider UI.
The route commit added 52 lines across the endpoint template and a new OpenCode manifest, then merged cleanly into main. It is intentionally narrow: one port, one host rule, one certificate, and one DNS declaration. The implementation does not broaden the Hermes VM’s network surface beyond the proxy path or invent an additional deployment mechanism for a service that already has a working systemd owner.
Looking Ahead
The next useful work is validation and consolidation, not another large layer of automation. For the DMZ Talos migration, I will verify the six VM instances, Talos bootstrap state, VIP reachability, Kubernetes node readiness, and the Argo CD registration against the declared cluster endpoint. I will also validate Vault login from the intended External Secrets and cert-manager identities, because those are trust-boundary integrations where a successful Terraform apply is only the first check.
I should correct the DMZ Argo CD environment label before it becomes a selector input, and review the initial Vault kv/* policy as DMZ applications are added. The right end state is narrower application-specific read paths where practical. The deferred production CI trigger also needs a separate decision backed by an environment-specific plan and safety review.
For OpenCode, the routing path is in GitOps and the edge is TLS-protected and allowlisted. The remaining operational check is to confirm certificate issuance, ExternalDNS convergence, Traefik route health, and end-to-end access from an allowlisted network. If the service later needs multiple users or less network-bound access, that should be a deliberate authentication design rather than an incremental weakening of the whitelist.