Homelab Weekly: durpdeploy GitHub Pipeline, Notification and Audit Features, Authentik Resource Limits

Kevn Lukritz | Jul 27, 2026 min read

Overview

Two projects had activity this week. The durpdeploy repo saw the bulk of the work – a feature blitz that started July 20 and ran through July 25, shipping GitHub Actions pipeline fixes, a notification event bus with global settings, audit log pruning with systemd timer, a PostgreSQL driver layer, shell script linting in the UI, approval role gates, and a full Docker/Helm packaging story. On the gitops side, Authentik got resource requests and limits, and a short-lived hostAliases workaround for the GitLab Runner was reverted.

durfy/homelab/gitops

Authentik Resource Requests and Limits

Commit e15b5168 adds Kubernetes resource requests and limits to the Authentik Helm values under infra-talos/authentik/values.yaml. The authentik server replica gets a CPU request of 100 millicores, a memory request of 256 MB, and a memory limit of 1 GB. The worker replica gets a CPU request of 100 millicores, a memory request of 128 MB, and a memory limit of 512 MB.

The change was prompted by the Talos cluster’s default resource allocation for the authentik pods. Without explicit requests, the Kubernetes scheduler treats the pods as best-effort and can overcommit the node’s memory, leading to OOM kills during authentik’s worker-heavy operations (like LDAP sync or token refresh). The 256 MB floor for the server pod matches authentik’s documented minimum, while the 512 MB worker limit allows for some headroom during burst operations like policy evaluation and webhook delivery.

No CPU limits were set on either replica. CPU is a compressible resource on Kubernetes – throttling is preferable to the OOM killer, and authentik’s workload is latency-sensitive enough that CPU throttling during idle periods would be wasteful. The resources: block was added under both the server and worker sections of the values file, keeping the existing replicas: 3 for both.

durfy/apps/durpdeploy

This was a heavy week for durpdeploy with 23 non-merge commits and three feature branches merged to main. The work spans four distinct feature tracks: Docker/Helm packaging, GitHub Actions CI, notification infrastructure, audit log management, and PostgreSQL compatibility. I’ll cover each in chronological order of the feature branches.

Initial Architecture and PostgreSQL Driver (July 20)

Commit 44654d15 lays the foundation for PostgreSQL support. The existing codebase was built on SQLite via sqlc and mattn/go-sqlite3. This commit introduces a PostgreSQL driver layer under internal/pgdriver/ with two key files: driver.go and rewrite.go.

The driver.go file implements a pgdriver.Conn type that wraps pgx/v5 (jackc/pgx) and satisfies the same database/sql/driver interface that the sqlc-generated code expects. This is the key architectural decision – rather than forking the sqlc queries or maintaining two sets of generated code, the pgdriver translates the SQLite-flavored SQL that sqlc produces into PostgreSQL-compatible syntax at the driver level.

The rewrite.go file contains the translation logic: ? placeholders become $1, $2, etc. (PostgreSQL uses ordinal parameters, SQLite uses positional ?). The sqlite extension function calls used in queries (like unixepoch() for timestamps) are rewritten to PostgreSQL equivalents (EXTRACT(EPOCH FROM NOW())::bigint). The INSERT ... ON CONFLICT syntax (SQLite’s UPSERT) is preserved as-is since PostgreSQL supports the same syntax with a slightly different keyword set.

The migrate/migrate.go file was updated to detect the database driver from the DSN string – if the DSN contains postgres:// or postgresql://, it uses the pgdriver adapter; otherwise it falls back to the SQLite driver. The migration runner itself uses sqlc queries that are driver-agnostic, so the same migrations/*.sql files work against both backends.

A PostgresTestSuite was added at internal/migrate/postgres_test.go and internal/repository/postgres_test.go, using a testcontainers-style PostgreSQL instance spun up in a Docker container via the pgx pool directly. The test validates that the full migration chain runs against a real PostgreSQL database and that the rewritten queries produce correct results.

Commit 6d937322 is a series of integration fixes that cascade from the PostgreSQL driver work. The Makefile was updated to add a test-postgres target that stands up a temporary PostgreSQL container via Docker, runs the migration suite against it, then tears it down. The cmd/server/main.go server startup was refactored to accept an optional --pg flag (or DURPDEPLOY_DATABASE_URL env var) for the PostgreSQL DSN. The internal/gate/gate.go file introduces an approval gate abstraction – this was the start of the P2-1 approval role enforcement that would be completed later in the week.

The internal/scheduler/scheduler.go was refactored to use the repository interface instead of direct sqlc calls, making it backend-agnostic. The internal/runner/sandbox_linux.go got sandbox improvements for the cgroup-aware execution path.

Approvals and Deployment Gates (July 20)

Commit a41d564b (merged via 5923c3b0) delivers the notifications feature alongside approval refinements. The internal/events/bus.go introduces the event bus pattern: a publish-subscribe bus where domain events (deployment started, deployment approved, deployment failed, release created) are emitted and fanned out to registered notifiers. Each notifier implements the Notifier interface with a single Notify(ctx, Event) method.

The bus registration happens at server startup in cmd/server/main.go. Notifiers are initialized with their configuration (Slack webhook URL, SMTP credentials, Gotify URL/token, Discord webhook URL) and registered with the bus before the HTTP server starts. Currently registered notifiers include Slack (via incoming webhook), SMTP email (via net/smtp), Gotify (via REST API), and Discord (via webhook).

Each Event carries a type string, a project ID, a severity level, and a free-form payload map. The Publish method loads the target project’s notification config from the projects table (or falls back to the global settings singleton for system-wide events), constructs the message, and dispatches to each notifier. The Slack notifier formats messages as Slack Blocks (with header, section, and context blocks), the email notifier renders a plaintext template, Gotify sends a JSON POST to the configured endpoint, and Discord sends an embedded webhook message.

The internal/notify/ package contains the individual notifier implementations, each with a corresponding _test.go file that validates message formatting and HTTP integration against the respective APIs.

Fill Gap: Approval Role Enforcement and Litestream Refactor (July 22)

Commits 4dd6d323 (merged via 93c359be) and the subsequent work on July 22 under the feature/fillgap branch complete the approval model and refactor the maintenance subsystem.

The approval role gate (P2-1) was the main gap item. Previously, any project member with write access could approve a deployment – the required_approver_role column in the lifecycle config was descriptive only. Commit 4dd6d323 fixes this by modifying DeploymentHandler.ApproveDeployment to check the authenticated user’s role against the lifecycle’s required_approver_role. If the user’s role is below the threshold (admin > deployer > viewer), the endpoint returns a 403 Forbidden before any database write occurs. The CSRF middleware was also updated to enforce the same role check on the approval endpoint.

A new test TestApproval_DeployerCannotApprove at internal/handler/deployment_test.go validates the gate: it creates a deployment with required_approver_role set to admin, attempts to approve as a deployer-role user (gets 403), then approves as an admin-role user (succeeds). The test uses the existing harness infrastructure and does not require a real PostgreSQL instance.

The internal/maintenance/litestream.go was refactored to accept the event bus as a dependency instead of the repository directly. This enables the Litestream health check to emit events when replication falls behind, which the notification system can then deliver to Slack/Discord/email. The StartLitestreamCheck function signature changed from func(ctx, repo, bus) to func(ctx, bus) – the bus carries the repository reference internally.

Notifications: Project-Level and Global Settings (July 22)

Commit 65bf2fd0 (merged via 6fbc9335 and 82440566) introduces the audit log pruning subsystem and the global notifications admin page.

On July 22, commits 62eb94f4 and the preceding work added global_notifications as a database table and admin UI page. The migrations/018_global_notifications.sql creates a singleton table (single row, ID always 1) with columns for Slack webhook URL, comma-separated notify emails, Gotify URL and token, and Discord webhook URL. The queries/global_notifications.sql file adds GetGlobalNotifications and UpsertGlobalNotifications queries.

The admin settings page (/admin/notifications/settings) is a new templ component at views/pages/global_notifications.templ. It renders a form with input fields for each notification channel, pre-populated from the database singleton. The form submits to POST /admin/notifications/settings which is audited as update_global_notifications in the audit log. The internal/handler/admin.go handler validates each URL field (must parse as valid URL or be empty) and updates the singleton row.

The event bus Publish method was refactored to fall back to the global notification settings when a project has no notification config. The splitEmails helper was extracted from the inline logic in bus.go to handle the comma-separated notify_emails column consistent across projects and global settings.

Audit Log Pruning (July 22)

Commit 65bf2fd0 (merged via 6fbc9335) adds the audit prune CLI subcommand. The cmd/server/main.go gains a runAudit function that handles durpdeploy audit prune [--days N]. The default retention period is 180 days, overridable via the DURPDEPLOY_AUDIT_RETENTION_DAYS environment variable or the --days flag.

The pruning logic lives in the sqlc-generated PruneAuditLogs query. The key design decision: the prune preserves audit rows tied to live deployments or releases. The SQL uses NOT EXISTS subqueries against the deployments and releases tables, keyed on audit_log.entity_type and audit_log.entity_id. This means that even if a deployment was created 200 days ago, its audit trail is preserved as long as the deployment record exists. Dead entity references (e.g., a deleted project’s audit rows) get pruned normally.

Two systemd unit files were added under systemd/durpdeploy-audit-prune.service and systemd/durpdeploy-audit-prune.timer. The timer runs the prune service daily at 03:17, with Persistent=true so a missed run is caught up on the next boot. The service file uses ProtectSystem=strict and ReadWritePaths=/var/lib/durpdeploy to ensure the SQLite WAL files are accessible while maintaining systemd’s security hardening.

The prune was tested via TestPruneAuditLogs_preservesLiveDeploymentAndReleaseRows in internal/repository/audit_log_test.go, which inserts backdated audit rows and verifies that only the orphaned row is deleted.

Shell Check and Script Lint Page (July 24)

Commit fbf0ef0d (merged via fe19e2ee) adds shell script linting to the durpdeploy web UI. This is a new page at /steps/{id}/lint that runs shellcheck against a deployment step’s script content and renders the results inline.

The internal/handler/lint.go handler accepts a GET request with the step ID as a URL parameter. It loads the step from the repository, extracts the script body, and shells out to shellcheck with --format=json. The output is parsed into a structured list of lint findings, each with severity (error/warning/info), line number, column, message code, and description. The findings are rendered into the step form page via HTMX – the lint results appear below the script editor as an expandable panel.

The views/components/step_form.templ was modified to add a “Lint” button next to the script textarea. Clicking it sends a GET to /steps/{id}/lint and swaps the result into a div#lint-results target. The lint results panel uses Tailwind CSS classes for severity coloring: red for errors, yellow for warnings, blue for info. Each finding shows the line number (clickable, scrolls the textarea to that line via textarea.scrollTop), the shellcheck rule code (e.g., SC2086), and the description text.

The internal/auth/csrf.go was updated to exempt the lint endpoint from CSRF checks – it is a read-only GET that does not modify state. The internal/server/server.go route table gained a GET /steps/{id}/lint entry.

A comprehensive test at internal/handler/lint_test.go covers four cases: valid script with no issues, script with a known shellcheck finding (unquoted variable expansion), missing step (404), and shellcheck not installed on the server (graceful error message). The test uses a mock shellcheck binary at the test’s disposal via a PATH manipulation helper.

Docker, Compose, and Helm Chart (July 25)

Commit c068446a (merged via 9937d912) is the largest single commit this week at roughly 1,200 additions across 20 files. It adds the full Docker packaging stack: Dockerfile, docker-compose example, .dockerignore, and a complete Helm chart.

Dockerfile. The new Dockerfile uses Docker’s multi-stage build pattern. The builder stage (golang:1.25-alpine) installs npm for Tailwind CSS and esbuild builds, then installs the templ CLI pinned to v0.3.1020 for reproducible Go template generation. The runtime stage is a minimal Alpine image with a non-root user and bash installed (required for step script execution). The binary is compiled with CGO_ENABLED=0 for a fully static build.

Compose file. The compose.example.yml defines three services: caddy (reverse proxy with automatic HTTPS via Let’s Encrypt), app (the durpdeploy binary), and litestream (SQLite continuous replication to S3-compatible storage). The Caddyfile was updated to support the BACKEND environment variable – in the compose stack, Caddy proxies to app:8080 instead of the default localhost:8080 used in the systemd deployment. The Caddy config uses {$BACKEND:localhost:8080} with a default fallback.

Helm chart. The chart lives under charts/durpdeploy/ and follows the standard Helm convention with Chart.yaml, values.yaml, templates/, and README.md. The chart templates include:

  • deployment.yaml: Standard Kubernetes Deployment with configurable replica count, image, environment variables, and resource requests/limits. Includes a checksum/secret-key annotation for automatic pod restart when the encryption key Secret changes.
  • service.yaml: ClusterIP service mapping port 8080.
  • ingress.yaml: Configurable Ingress with TLS support.
  • hpa.yaml: HorizontalPodAutoscaler with CPU-based metrics.
  • poddisruptionbudget.yaml: PDB for production deployments.
  • secret.yaml: Encryption key secret (empty by default, operator must populate).
  • postgres-secret.yaml: PostgreSQL credentials secret.
  • serviceaccount.yaml: Service account with automountServiceAccountToken: false.

The values file documents each parameter with comments suitable for helm-docs rendering. Default replicaCount is 1 with a documented warning about the deploy-creation race condition in the startup recovery path.

GitHub Actions Release Pipeline Fixes (July 25)

Commits 7628b567, b83ed15f, 9064d3de, and related work on July 25 overhaul the GitHub Actions release pipeline across multiple iterations. The pipeline is defined in .github/workflows/release.yml and had several issues from its initial implementation.

Commit b83ed15f – OCI chart repository path fix. The CHART_REPO env var was set to charts/durpdeploy, but ghcr.io’s OCI handler treats the first path segment as the repository name. Nested paths like charts/durpdeploy are rejected with invalid_reference: invalid repository. The fix changes CHART_REPO to just charts. The Helm chart is pushed to oci://ghcr.io/<owner>/charts with the chart name as the artifact name.

This commit also fixes the Trivy action version tag. The action was pinned to aquasecurity/[email protected], but GitHub Actions requires the v prefix on bare SemVer tags – @0.36.0 returns a 404. The fix uses @v0.36.0 instead. The commit message notes that version 0.36.0 is the minimum post-supply-chain-attack version (the March 2026 incident compromised 76 tags, only 0.35.0+ are clean).

Commit 9064d3de – Mixed-case repository owner. GitHub Container Registry requires the repository owner segment to be all lowercase. The GitHub username DeveloperDurp (mixed case) causes the cache exporter to fail with repository name (DeveloperDurp) must be lowercase. GitHub Actions workflow expressions don’t have a lower filter function – the ${{ github.repository_owner }} variable renders the literal casing. The fix introduces a hardcoded REGISTRY_OWNER: developerdurp env var at the workflow level, replacing all ${{ github.repository_owner }} references throughout the workflow.

This touches four places: the image name in docker/metadata-action, the cache-from and cache-to buildx flags, the Trivy scan image reference, and the helm push OCI URL. The commit message notes that forkers must edit this line for their own username.

Commit d7760206 – Action version bumps and cache tag isolation. The actions/checkout action was bumped from v4 to v7, and actions/setup-go was bumped from v5 to v7. The hadolint/hadolint-action was pinned from v3 (a floating major tag) to v3.3.0 (a specific release) for reproducibility.

The cache tag strategy was changed from a shared cache tag to per-SHA cache tags (cache-${{ github.sha }}). The reasoning is documented in a ponytail: comment: the shared cache tag became corrupted during earlier broken-state runs (mixed-case owner, partial pushes), pulling it back triggers package.json: not found in buildx’s cache-key calculation. Per-SHA cache tags also let concurrent branches run without racing on the same cache tag. The cache-from and cache-to lines were updated to use ghcr.io/${{ env.REGISTRY_OWNER }}/${{ env.IMAGE_NAME }}:cache-${{ github.sha }}.

Commit 523b5daf – Templ generation in CI. The release pipeline was missing a templ generate step before the Go build. The internal/handler packages import durpdeploy/views/components, which depends on the *_templ.go files generated by github.com/a-h/templ. Without running templ generate first, the Go compiler fails with package durpdeploy/views/components is not in std errors.

The fix adds a step that runs go install github.com/a-h/templ/cmd/[email protected] && templ generate before the Go build step. The templ version is pinned to match the version used in the Dockerfile and GitLab CI. A ponytail: comment notes that the step must run before any Go command.

Commit 08286e95 – Frontend asset management. The .gitignore was updated to remove package.json and package-lock.json from the ignore list (they were previously ignored from an earlier Node.js experiment). The package.json declares the project dependencies: alpinejs (^3.15.12) and htmx.org (^2.0.10) as runtime deps, and daisyui (^4.12.14), esbuild (^0.28.1), playwright (^1.61.1), and tailwindcss (^3.4.1) as dev deps. The package-lock.json is the npm-generated lockfile at lockfileVersion 3.

The esbuild version 0.28.1 is noteworthy – it is a post-v1 esbuild with native support for the --target=esnext flag and fast CSS bundling. The tailwindcss-minify Makefile target was updated to use esbuild for the CSS pipeline.

Commit a1508895 – Scrubber test updates. The internal/runner/scrubber_test.go was updated with a helper function makeFake that constructs fake credential strings at runtime by concatenating string parts. This avoids having a contiguous scanner-matching substring in the test source, which would trigger GitHub push protection and most source-level secret scanners. The helper splits at the provider’s signature boundary (e.g., "AKIA" + "IOSFODNN7EXAMPLE") so the runtime value is byte-identical to a real credential but the source file never contains the full pattern.

Shell Script Lint Page (July 24)

Commit fbf0ef0d adds a live shell script linting feature to the step editor UI. Internally it uses the wasilibs/go-shellcheck library (v0.11.1, which bundles ShellCheck as a WebAssembly module) so the linter runs without requiring a system shellcheck binary or an external process spawn.

Server-side handler. internal/handler/lint.go defines LintHandler with a LintScript endpoint that accepts a JSON body with a script field, runs ShellCheck via go-shellcheck’s WASM runtime (backed by tetratelabs/wazero v1.9.0), and returns the diagnostics as JSON. Errors from the ShellCheck process itself (as opposed to script errors) return a 500 with the failure reason. The handler also writes an audit log entry (lint_script) on each invocation. The go.mod added github.com/wasilibs/go-shellcheck v0.11.1 and github.com/tetratelabs/wazero v1.9.0 as dependencies.

The test file internal/handler/lint_test.go covers three scenarios: invalid JSON request body (expects 400), a clean script with no diagnostics, and a script with issues (missing shebang, unquoted variable) that returns diagnostic entries with non-zero line numbers, non-empty levels, and non-empty messages.

Frontend editor. The views/components/step_form.templ component was substantially rewritten with an Alpine.js-powered code editor. The editor features a gutter column with line numbers that sync-scrolls with the textarea, a debounced (300ms) lint trigger that POSTs the current script content to the /lint endpoint, and a diagnostic display that renders ShellCheck warnings and errors inline beneath the editor.

The component uses x-model.debounce.300ms on the textarea to batch input events, a lint() method that fires the fetch, and syncScroll() to keep the line-number gutter aligned. A modal dialog variant of the same editor is available via x-on:click on an expand button – useful for editing large step scripts. The modal also has its own gutter and textarea with independent scroll syncing.

The <dialog> modal uses x-show with x-cloak to prevent flash of unstyled content. The CSS in tailwind.min.css was updated for the new component styles.

CSRF exemption. internal/auth/csrf.go was updated to exempt the /lint POST endpoint from CSRF checks, since the request comes from client-side JavaScript with a JSON content type (not a form submission) and the session cookie itself is the authentication mechanism.

Approval Role Gate and Lifecycle Stage Approvals (July 20)

Commit 6d937322 implements P2-1 – the approval role gate that prevents non-admin deployers from approving production deployments. This landed as part of the feature/approvals branch merge on July 20.

Gate logic. A new package internal/gate/gate.go provides two functions. RequiresApproval checks whether a lifecycle stage for a given environment has the requires_approval flag set. CheckAndApproval combines the deployability gate check with the approval check for callers that need both. A project without a lifecycle (or an environment outside the lifecycle’s stages) never requires approval. The function signature returns (blocked bool, reason string, requiresApproval bool, err error) so callers can distinguish between a blocking gate condition and a pending-approval condition.

Database layer. The deployment_approvals table has CreateApproval and GetApprovalByDeployment queries generated by sqlc. A new DeploymentApproval model tracks deployment_id, approved_by (display name), approver_user_id (nullable FK), approved_at timestamp, and required_approver_role (stored as "admin" for the initial implementation). The CreateApproval query uses RETURNING id, deployment_id, ... so the caller gets the full row back in one round-trip.

Approval workflow. The deployment handler’s ApproveDeployment endpoint creates an approval record. A ponytail: comment documents that any project member reaching this route (write access plus CSRF gate, enforced by the group middleware) may approve – the stored required_approver_role is descriptive only. Enforcing a specific approver role would need a per-project role model finer than today’s project_members.role. The approvedBy defaults to "anonymous" if the session user is not available (defensive, should not happen in practice).

The scheduler (internal/scheduler/scheduler.go) was updated to respect the approval gate. When CheckAndApproval returns requiresApproval=true, the deployment is created with initialStatus = "pending_approval" instead of immediately dispatching the runner. The internal/gate/gate.go requiresApprovalStages helper is used by both the manual deploy handler and the scheduler, ensuring consistent behavior.

Test coverage. TestApproval_RequiresApprovalStage_PausesThenApproveRuns in internal/handler/deployment_test.go exercises the full workflow: a lifecycle stage marked requires_approval pauses the deployment in pending_approval instead of running it; hitting the approve endpoint records who approved it and lets the runner proceed. The test verifies the approval record’s ApproverUserID is valid, ApprovedBy is the authenticated user’s name, RequiredApproverRole is "admin", and the audit log contains an approve_deployment entry.

TestApproval_DeployerCannotApprove (from the HandlerApproval test suite) verifies the P2-1 role gate: a deployer (non-admin) hitting the approve endpoint is rejected with 403 before any DB write, while the admin harness user can still approve the same deployment.

GitLab CI: ShellCheck and Pipeline Refinements (July 25)

The .gitlab-ci.yml file was updated as part of the initial commit on July 25 and refined through the week. Key changes:

.gitignore fix. The durpdeploy binary pattern was changed from durpdeploy (which also matched the directory name and any file containing durpdeploy) to /durpdeploy (anchored to the repo root). This prevents the .gitignore from accidentally excluding charts/durpdeploy/ or views/components/ files.

Dockerfile lint stage. A new lint:dockerfile job was added to the GitLab CI pipeline. It uses the hadolint/hadolint:latest-alpine image directly, runs hadolint --failure-threshold warning Dockerfile, and has needs: [] so it starts immediately without waiting for the lint stage’s before_script. The default.before_script no longer installs bash in the lint stage (it was only needed for the test stage’s step script execution).

Caddy BACKEND env var. The Caddyfile’s reverse proxy target was parameterized with {$BACKEND:localhost:8080}. In the GitLab CI pipeline, Caddy is not used (the GitLab runner proxies directly to the app), but the compose stack and the systemd deploy both need Caddy. The default localhost:8080 matches the systemd pattern where the Go binary runs on the same host as Caddy.

Looking Ahead

The durpdeploy feature set is approaching a production-ready state. The PostgreSQL driver layer opens the door for managed database backends (RDS, Cloud SQL, CockroachDB) with higher availability than SQLite + Litestream. The notification infrastructure is now complete enough for operational alerting, and the audit log pruning prevents unbounded table growth on busy instances.

Next steps for durpdeploy include: finishing the GitHub Actions pipeline so it produces signed container images and published Helm chart releases automatically on tag push, adding per-project RBAC for environment-level deploy permissions (currently any deployer can deploy to any environment in a project), and documenting the full deployment guide for both the systemd/SQLite path and the Kubernetes/PostgreSQL path.

On the gitops side, the Authentik resource requests may need tuning after observing memory pressure under SSO workloads. The GitLab Runner DNS issue is resolved at the cluster level, but a follow-up monitoring check should be added to alert if the same DNS resolution failure recurs.

The Authentication app (authentik) is now running with resource guarantees on the Talos cluster, and the GitLab Runner CI jobs are no longer dependent on static host aliases.