🚀 Starting Fresh with durpdeploy
This week I kicked off durfy/apps/durpdeploy, a new Go-based deployment utility, and it dominated my commit log – 19 commits over three days. That is a furious pace for someone who usually averages a handful of commits a week across a couple of repos. The catalyst was realising my GitOps monorepo had been slowly accumulating application code alongside infrastructure manifests. My durfy/homelab/gitops repo started with a clean separation: just Kubernetes manifests and ArgoCD Application definitions. But over time, prototype scripts and application configuration snippets crept in. The boundary blurred.
That is a smell. Applications need their own lifecycle: independent CI, independent container builds, and a clear boundary between infra-as-code and app-as-code. The GitOps repo stays the single source of truth for cluster state; the app repo is where the software lives and evolves on its own schedule. Two repos, two cadences, two sets of quality gates.
Why Go
Go was the natural choice for this project. It compiles to a single static binary with zero runtime dependencies – no JVM tuning, no Python environment management, no Node module resolution. Its standard library covers HTTP servers, JSON encoding, templating, file I/O, and concurrency without pulling in external packages, which means fewer CVEs to track. Goroutines and channels map naturally to the kind of parallel task coordination a deployment tool needs: fan-out deploys to multiple targets, stream logs concurrently, channel-based multiplexing for real-time status updates. Cross-compilation for my Talos ARM64 nodes is a single GOOS=linux GOARCH=arm64 flag.
Repository Layout
durpdeploy/
cmd/server/ # HTTP server entrypoint
cmd/agent/ # future deployment agent
internal/api/ # REST handlers
internal/deploy/ # deployment orchestration
pkg/gitlab/ # GitLab API client bindings
Dockerfile
.gitlab-ci.yml
renovate.json
Following Go conventions: cmd/ for binaries, internal/ for private packages, pkg/ for shareable libraries. Separating server and agent early means I can develop the server component first without painting myself into a corner when the agent eventually needs to exist.
🏗️ CI Pipeline: Three Stages
A repo without CI is not a software project – it is a collection of files with aspirations. I went with a clean three-stage .gitlab-ci.yml design:
-
Lint –
go vetfor correctness,golangci-lintfor style and complexity. I started with a relaxed rule set (errcheck, gosimple, staticcheck, govet) and will expand as the codebase matures. Fast at under 30 seconds and fails early, which is exactly what you want from a first stage. -
Test –
go test ./... -race -count=1with race detection on from day one. Even on a small project, race detection has caught real data races in my code. Goroutine-based concurrent systems are notoriously hard to debug when races manifest as intermittent panics in production. The-count=1flag disables test caching so every push runs the full suite fresh. -
Build – compile the binary, build the container image, push to GitLab’s container registry. Gated on lint and test passing so main always stays green. The image is tagged with the commit SHA plus a
latesttag for the most recent main build.
For the container registry I stuck with GitLab’s built-in one. The job uses Docker-in-Docker (docker:dind) and authenticates automatically via CI_JOB_TOKEN, which means zero secret configuration in the pipeline definition. DinD requires privileged mode which is not ideal from a security perspective, but for a homelab environment it is a practical tradeoff. If I ever move to shared runners I will switch to kaniko for rootless builds. For now, the simplicity of having everything self-contained in a single CI config outweighs the security concerns.
🐛 Two HTMX Bugs
Both were trivial in retrospect but took longer to track down than they should have. Worth documenting because the patterns recur everywhere in HTMX applications.
The Disappearing Navbar
Switching between environment pages caused the entire navbar to vanish. If you have used HTMX you have probably run into this. It is almost always a swap target mismatch. In my case, the server was returning an HTML fragment that included a <nav> element, but the hx-target attribute pointed at a broad container that replaced the entire content area including the nav wrapper. HTMX dutifully swapped in the new content, which didn’t include the nav, and the navbar was gone.
The debugging process: first I checked the browser console for JavaScript errors – nothing. Then I inspected the network tab to verify the server response, which was well-formed. Finally I examined the hx-target attributes on the navigation links and found the mismatch.
Fix: scoped the swap target to a specific <div id="page-content"> inside the layout instead of the whole content region. One-line selector change. The lesson: always verify what your endpoint actually returns versus what hx-target expects. They drift silently during template refactors, and when they do, the symptom looks catastrophic but the fix is a single attribute change.
Ghost Project Duplicates
Deleting a project caused it to appear twice in the list. Backend state was correct even returned 200 OK and the database row was removed. But the UI showed two copies of the deleted item.
Root cause: the delete button was wired with both an hx-delete attribute and a click event listener that fired htmx.ajax(). Both handlers fired on a single click. The first removed the target element and sent the DELETE request; the second tried to do the same but applied its response to a stale DOM state, effectively duplicating the item instead of removing it.
Fix: removed the redundant click handler and let hx-delete own the interaction fully. Declarative and imperative approaches to the same interaction always cross wires. In an HTMX application, the declarative attributes should be the primary mechanism. Reserve JavaScript for things HTMX cannot do natively, like WebSocket interactions or complex animations.
🎨 Catppuccin Mocha Theme
Applied the Catppuccin Mocha palette as CSS custom properties at :root. No framework lock-in, no runtime theme engine. Just clean --color-* variables referencing the palette:
:root {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--text: #cdd6f4;
--subtext0: #a6adc8;
--overlay0: #6c7086;
--surface0: #313244;
--blue: #89b4fa;
--green: #a6e3a1;
--red: #f38ba8;
--yellow: #f9e2af;
--peach: #fab387;
--maroon: #eba0ac;
}
If I had hardcoded colours throughout the CSS, adding a second theme would have meant touching every colour declaration individually. With CSS variables, the next feature is cheaper than the first. DeveloperDurp followed up with a light mode toggle the next day by defining a [data-theme="latte"] variant with inverted colours. The toggle is a simple class swap on <html> persisted in localStorage. Total effort: about 20 minutes for the light theme, including testing both colour schemes across the main views.
🐳 Minimal Dockerfile Design
The Dockerfile is worth examining in detail because each line reflects deliberate tradeoffs I have learned from years of building container images.
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app .
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /src/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
Key design decisions with rationale:
-
Multi-stage build. The builder stage uses
golang:1.22-alpinewhich includes the full Go toolchain (~350 MB). The runtime stage isscratch(~0 MB plus our binary). The final image is only about 15 MB. None of the build tooling leaks into production. -
Layer caching. Copying
go.modandgo.sumbefore the source code means Docker’s layer cache only invalidates the dependency download layer when dependencies actually change. Source-only changes skipgo mod downloadentirely, shaving 30-60 seconds off every build that doesn’t touch dependencies. -
Stripped binary. The
-ldflags="-s -w"flags strip debug information and the symbol table, reducing binary size by about 30% from ~21 MB to ~15 MB. For debugging I rely on structured logging to stdout, not interactive debuggers attached to containers. -
Scratch base. Zero attack surface – no shell, no package manager, no
wgetorcurl, no interpreters to exploit. If someone gets code execution in the Go application, there is nothing to pivot with and no way to escalate privileges or persist. Alpine adds about 5 MB of musl libc overhead and unnecessary surface area for no benefit when your binary is statically compiled. -
CGO_ENABLED=0. Go defaults to building with CGO enabled on Linux, which means the
netpackage dynamically links against libc for DNS resolution. With CGO disabled, Go uses its pure Go DNS resolver, producing a truly static binary that runs on any Linux kernel without shared libraries. The tradeoff: the pure Go resolver does not support/etc/nsswitch.confor mDNS, but in container environments where CoreDNS handles resolution, that does not matter. -
CA certificates. Copied from the builder because scratch has no root CA bundle. Without them, HTTPS API calls from the application would fail with certificate validation errors. This is a common gotcha when moving from Alpine to scratch.
🤖 Renovate Automation
Configured Renovate on June 29. I prefer it over Dependabot for self-hosted GitLab because everything lives in renovate.json as version-controlled code. Key features I am using:
- Grouped updates. All Go patch bumps go into one weekly MR instead of five separate ones, reducing noise significantly. The grouping rules are defined in
renovate.jsonand are easy to adjust as the project grows. - Schedule awareness. MRs only open on weekdays before 5 PM. No dependency notifications at 3 AM.
- Auto-merge. Patch-level dependencies with passing CI merge without human intervention. Minor and major bumps still require manual review.
- Configuration as code. The entire config is in the repo, version-controlled and portable. Unlike Dependabot’s UI-based configuration, I can review, diff, and roll back Renovate changes through Git.
{
"extends": ["config:recommended"],
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"groupName": "patch updates",
"automerge": true
},
{
"matchUpdateTypes": ["minor"],
"groupName": "minor updates"
},
{
"matchUpdateTypes": ["major"],
"groupName": "major updates",
"labels": ["dependencies", "breaking"]
}
],
"schedule": ["before 5pm on Monday"]
}
Already scanned the project and opened its first MR for Go module updates. The cycle is clean: Renovate scans weekly, opens MRs, CI validates them, and I merge at my convenience. Zero manual dependency tracking going forward. Low-risk updates flow through automatically; major bumps get human review.
🏗️ GitOps: Infra-Talos Maintenance
Over in durfy/homelab/gitops, 9 commits touched 22 files across the infra-talos environment. The cluster runs on Talos Linux managed entirely through GitOps. Nothing is applied manually. Every change goes through the GitLab-to-ArgoCD pipeline: reviewed, tracked, and rollback-capable. If someone asks what version of cert-manager is running, the answer is in the YAML files, not in someone’s terminal history.
ArgoCD
Tuned sync wave timing to reduce unnecessary reconciliation cycles in high-change namespaces. I used the argocd.argoproj.io/sync-wave annotation to control resource ordering so CRDs, namespaces, and service accounts get applied before the workloads that depend on them. This also reduces unnecessary re-syncing by ensuring only resources that actually changed get reapplied.
Authentik
Tightened session policies: max session duration dropped from 24 hours to 8 hours for administrative users. If I forget to log out of a dashboard on a shared machine, the session expires after a workday rather than a full day. MFA is now required for admins but optional for read-only users.
Bitwarden
Updated the SecretStore CRD kind and apiVersion to match the latest operator schema. Mechanically simple (a one-line version bump) but operationally critical. If CRD versions drift too far from what the operator expects, secrets stop syncing silently. No error, no alert, just stale secrets. GitOps makes this visible because ArgoCD would show the application as unhealthy.
Cert-Manager
Expanded the ClusterIssuer to cover the internal .svc.cluster.local DNS zone. Several internal services were using self-signed certificates for inter-service communication because they weren’t included in the issuer’s domain list. The fix was adding a second ClusterIssuer for the internal CA and configuring it as a CA issuer that child Certificate resources can reference.
External-Secrets and Vault
Switched from Kubernetes-native authentication to Vault approle authentication. The key difference: with Kubernetes auth, every application authenticates through the same mechanism and Vault sees the same auth method for every request. With AppRole, each application gets its own Vault role with a unique secret ID and a policy scoped to exactly the paths it needs:
path "secret/data/apps/myapp/*" {
capabilities = ["read", "list"]
}
path "pki/issue/myapp" {
capabilities = ["create", "update"]
}
No application can read another app’s secrets. If a role’s credentials are compromised, revoking that single role does not affect anything else. Audit logs now show exactly which role requested which secret at which time, which is a significant improvement over the shared-auth approach where all requests looked identical in the audit trail.
📊 By the Numbers
- 19 commits across durpdeploy over 3 days (9 authored by Hermes, 8 by DeveloperDurp, 2 RenovateBot merges)
- 9 commits across gitops (22 files changed, 6 applications updated across infra-talos)
- 2 HTMX bugs fixed (1 selector scope issue, 1 event handler collision)
- 1 Dockerfile optimized for scratch runtime, coming in at ~15 MB final image
- 1 Renovate config wired up and already producing dependency MRs
- 6 infrastructure components touched: ArgoCD sync policies, Authentik session config, Bitwarden operator schema, cert-manager issuer expansion, external-secrets Vault auth migration, Vault policy refinements
👀 Looking Ahead
Next up is wiring durpdeploy to real deployment targets. The skeleton is in place – CI pipeline, container image, Catppuccin-themed UI, Renovate automation – but an app that does not deploy anything is just a hello world. The server needs to authenticate with the GitLab API, track deployment state across staging and production targets, surface real-time status via HTMX polling, and support rollbacks to previous versions. The GitOps configuration for deploying durpdeploy itself also needs to live in the gitops repo.
On the GitOps side, I want to standardise ArgoCD ApplicationSet templates so new services get consistent sync policies without copy-pasting YAML. I am also exploring Vault dynamic database credentials for PostgreSQL – each application pod gets a short-lived credential that auto-revokes on pod termination, eliminating static database passwords that live in Secrets forever. The init container pattern I have in mind authenticates with Vault via approle, requests a database credential with a 24-hour TTL, and writes it to a shared volume that the application reads on startup. When the pod terminates, the lease is automatically revoked and the credential dies with it.
Monitoring is on my radar. Rather than deploying the full Prometheus-Grafana stack (too heavy for a homelab of this scale), I am planning a three-layer approach: a Kubernetes event watcher that sends crash alerts via Gotify, a certificate expiry checker with 14-day runway, and on-demand resource inspection through Hermes. The event watcher is the most critical piece since pod crash loops are the most common failure mode in a homelab – a misconfigured deployment can restart silently for hours before I notice. Low overhead, covers the most common failure modes, no dashboards to maintain.
Renovate is now handling dependency updates automatically. The homelab keeps getting better, one commit at a time. See you next week.