GitOps: DMZ InternalProxy Updated
One commit landed in the gitops repo this week, updating the DMZ environment’s internalproxy configuration. The change added Kubernetes Endpoints and Service definitions for a new Hermes VM at 192.168.12.50, exposing its Ollama API port (11434). The existing ollama.yaml template was updated to point the reverse proxy at the new Hermes service instead of the old Ubuntu host on port 11435. This was a straight service migration: two files changed, one commit, no additional infrastructure work needed beyond updating the endpoint reference.
DurpDeploy: Heavy Feature Development
DurpDeploy saw 10 feature branches merge to main this week, each building on the previous. The application is a self-hosted deployment runner – a Go binary backed by SQLite that manages projects, releases, environments, and step-based deployment scripts. Here is the technical breakdown of each merge.
Job Templates (Jun 30)
This branch introduced an AGENTS.md file documenting the project’s conventions (critical for any AI-assisted development on the codebase), plus the step_templates feature. The migration 009_step_templates.sql created a new table:
CREATE TABLE IF NOT EXISTS step_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
script_body TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
A full CRUD handler (internal/handler/step_template.go) was added with the following endpoints:
GET /templates,POST /templates– list and create templatesGET /templates/{id}/edit,PUT /templates/{id}– edit existing templatesDELETE /templates/{id}– delete a templatePOST /projects/{id}/steps/{stepId}/save-as-template– promote an existing step to a reusable templatePOST /projects/{id}/steps/from-template/{templateId}– instantiate a template as a new step in a project’s step list
The views layer got new templates (template_form.templ, template_list.templ, templates_list_page.templ) and the server router registered the new routes. The Makefile was updated with js-build and npm-install targets; the build pipeline now runs esbuild static/js/app.js --bundle --minify to bundle JavaScript.
UI Enhancements (Jun 30)
The UI enhancement branch was a broad layout and styling pass. Every table in the application was standardized to table table-zebra table-fixed w-full with explicit percentage-width columns. The Tailwind CSS source file (static/css/input.css) got a layer block to force width: 100% !important and table-layout: fixed on all .table elements, overriding DaisyUI defaults.
The layout container was changed from container mx-auto to w-full px-4 sm:px-6 lg:px-8 so tables and content fill the full viewport width. A dark/light theme toggle was added using Alpine.js and localStorage:
x-data="{ theme: localStorage.getItem('theme') || 'mocha' }"
x-init="document.documentElement.setAttribute('data-theme', theme);
$watch('theme', val => { document.documentElement.setAttribute('data-theme', val);
localStorage.setItem('theme', val) })"
The mocha theme from DaisyUI is the default with a toggle to light mode. The htmx.js bundle was removed from the repo (moved to CDN), reducing the static directory size by ~22KB. Button text in table actions was shortened for space – “Save as Template” became “Save Template”.
Lifecycle Gates (Jul 1)
The lifecycle feature was the week’s most architecturally significant change. A new lifecycles table defines ordered stage sequences (e.g., Dev -> Test -> Prod). Each stage references an environment and has a sort_order. Projects can be optionally bound to a lifecycle by setting their lifecycle_id.
The gate evaluation logic lives in internal/handler/deployment.go’s evaluateGate function. For a lifecycle-bound project, the gate checks three conditions:
- The target environment must be a stage in the project’s lifecycle (hard block – force=true cannot bypass this)
- If the target is the first stage, deployment is always allowed
- For subsequent stages, the gate queries
GetLatestSuccessfulDeploymentForReleaseEnvfor the previous stage. If that release version hasn’t succeeded in the prior stage, the deployment is blocked with a 422 response (bypassable viaforce=true)
The deployments table got a forced column to track bypassed deployments. A new query ListLifecycleStageEnvironmentIDs returns stage env IDs in sort order. The E2E test suite was extended with tests for the full Dev-Test-Prod chain, skip-blocking, and force-deploy.
Projects Overhaul (Jul 4)
This was the largest branch by diff size (272 additions and deletions across many files). Key changes:
Air hot-reload: An .air.toml configuration was added, using the air-verse/air live-reload tool. The make dev target was changed from an instruction comment to running go run github.com/air-verse/air@latest, watching .go, .templ, and .sql files.
Secret/Masked Variables: Both variables and release_variables tables got a secret INTEGER NOT NULL DEFAULT 0 column. The runner already injects variables as environment variables, so secrets are masked in the UI by not displaying their values (the handler redacts them from deployment logs).
Dedicated Deploy Page: A new GET /projects/{id}/deploy route renders a full deploy form with release selection, env dropdown (filtered by lifecycle stages), and gate state per environment. The availableEnvsForDeployPage helper computes release-aware gate state – when a release is selected, only environments whose prior stage has succeeded with the same release are shown as deployable.
Navigation Fixes: After form submission, HTMX responses now use HX-Redirect header instead of fragment swaps to navigate to the project detail page. This prevents Alpine.js state corruption from partial DOM swaps. Delete navigation was also unified to always redirect to /projects.
Edit/Delete Button Relocation: Edit buttons were moved from the projects list to the project detail page. Delete buttons were moved from the detail page to the edit form (one click away from the management context, not the overview). The projects list is now fully read-only.
Configurable Step Timeout (Jul 4)
The runner previously hard-coded 5 * time.Minute per step. This branch made it configurable. The steps table got a timeout_seconds INTEGER NOT NULL DEFAULT 0 column (zero = use the 5-minute default). The runner’s Run method now reads step.TimeoutSeconds from the JSON snapshot and creates the context timeout accordingly:
const defaultStepTimeout = 5 * time.Minute
d := defaultStepTimeout
if step.TimeoutSeconds > 0 {
d = time.Duration(step.TimeoutSeconds) * time.Second
}
stepCtx, stepCancel := context.WithTimeout(runCtx, d)
The timeout value is snapshotted into steps_json at release creation time, so each release captures the exact timeout configuration. The UI shows timeout cells (default or formatted duration like “30s”). The step form and inline-edit form both accept timeout_seconds as an input field.
A new E2E test (F3.2b) validates that a step with a 1-second timeout running a 10-second sleep fails in under 25 seconds (well under the 5-minute default). The runner also logs a specific message: step "TimeoutStep" timed out after 1s.
Deployment Notes and N+1 Fix (Jul 4)
The “updates” and “Update deployments” commits (two feature branches that merged the same day) added several enhancements:
Deployment Notes: The deployments table got a note TEXT column (migration 006_deployment_notes.sql). The deploy form includes a textarea for optional notes. Notes appear as a styled blockquote on the deployment detail page and are stored as sql.NullString. The RedeployDeployment endpoint auto-generates a note of the form "Re-run of #<id>" to track lineage.
N+1 Query Fix: The deployment list page was making three additional queries per row (one each for release, project, and environment). A new ListDeploymentsWithRefs query uses a single JOIN to fetch everything:
-- name: ListDeploymentsWithRefs :many
SELECT
d.id, d.release_id, d.environment_id, d.status,
d.started_at, d.finished_at, d.created_at, d.forced, d.note,
p.name AS project_name,
r.version AS release_version,
e.name AS environment_name
FROM deployments d
JOIN releases r ON d.release_id = r.id
JOIN projects p ON r.project_id = p.id
JOIN environments e ON d.environment_id = e.id
ORDER BY d.created_at DESC;
Redeploy Button: A POST to /deployments/{id}/redeploy creates a new deployment with the same release and environment, bypassing the gate (since the prior deployment already passed through the lifecycle). The button appears on terminal-state deployment detail pages. This is a deliberate design choice: gate evaluation is per-release, so if a deployment to stage 2 failed for a transient reason, re-running does not require re-promoting through stage 1.
Cron / Scheduled Deployments (Jul 5)
This branch added the scheduled_deployments table and a background scheduler package:
CREATE TABLE IF NOT EXISTS scheduled_deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
environment_id INTEGER NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
cron TEXT NOT NULL,
next_run_at INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_fired_at INTEGER,
note TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
The scheduler (internal/scheduler/scheduler.go) uses github.com/robfig/cron/v3 with a custom parser supporting standard 5-field cron expressions. It runs a goroutine that ticks every 30 seconds, queries ListDueScheduledDeployments (WHERE next_run_at <= now AND enabled = 1), and triggers the runner for each due schedule. After firing, it updates next_run_at using the cron parser and marks last_fired_at.
The scheduler integrates with the lifecycle gate via internal/gate/gate.go – before firing a scheduled deployment, it evaluates the gate state and logs a warning if blocked, rather than silently failing or bypassing the gate.
The E2E test creates a per-minute schedule, waits 100 seconds for two ticks, and verifies a new deployment was created with the note “Scheduled: morning deploy”. The schedule list page shows the cron expression and enabled/disabled state.
Polish, Pagination, Retry, Approvals, and More (Jul 5)
The “polish” branch was the week’s second-largest, adding half a dozen features in a single commit:
Deployment List Filtering and Pagination: The old unbounded deployment list was replaced with a filtered, paginated version. New queries ListDeploymentsWithRefsFiltered and CountDeploymentsWithRefsFiltered accept optional filters for project, environment, status, and date range. The UI includes a filter bar with dropdowns for project/env/status and date inputs for from/to. “Load more” uses HTMX to append rows via hx-get with hx-swap="beforeend" and OOB swap on the load-more region.
Dashboard Metrics: The index page now shows CountDeploymentsToday, ListRunningDeploymentsWithRefs (currently pending/running deployments), and ListLatestDeploymentPerReleaseEnv (latest deployment per release+env combo using ROW_NUMBER() OVER (PARTITION BY ...)).
Step Template Versioning: A step_template_versions table tracks history. When a template is updated, the handler creates a new version record:
latest, err := qtx.GetLatestStepTemplateVersionNumber(ctx, updated.ID)
nextVersion := latest.(int64) + 1
qtx.CreateStepTemplateVersion(ctx, db.CreateStepTemplateVersionParams{
TemplateID: updated.ID,
VersionNumber: nextVersion,
Name: updated.Name,
ScriptBody: updated.ScriptBody,
})
The history page at GET /templates/{id}/history shows all versions with version numbers and script bodies, ordered newest-first. Template deletion cascades to versions via ON DELETE CASCADE.
Deployment Log Export: A new GET /deployments/{id}/logs.txt endpoint streams stored deployment logs as plain text. No new dependencies – just a streaming query that writes lines in order.
Health Endpoint: GET /healthz returns {"status":"ok","db":"ok"} with a SELECT 1 probe. Required for container orchestration. The route is registered before all other routes to ensure it’s always accessible regardless of middleware ordering.
Step Retry on Failure: The max_retries column on steps controls how many times the runner retries a failed step before marking the deployment failed. The runner’s Run method was refactored to extract runStepAttempt as a helper, with a retry loop:
maxAttempts := int(step.MaxRetries) + 1
for attempt := 1; attempt <= maxAttempts; attempt++ {
lastErr = r.runStepAttempt(ctx, runCtx, deploymentID, step, logWriter, envMap, secretValues, attempt)
if lastErr == nil {
break
}
// check for cancellation between retries
if attempt < maxAttempts {
logWriter.Write([]byte(fmt.Sprintf("step %q: retrying (attempt %d of %d)\n", ...)))
}
}
Approval Gate: Separate from the lifecycle promotion gate, an approvals table allows lifecycle stages to require explicit approval. When a deployment reaches a stage marked requires_approval, the runner sets status to pending_approval. The UI shows an Approve form with an approved_by text field. After approval, status transitions to pending and the runner picks it up for execution. The requires_approval flag is on lifecycle_stages and can be toggled via a PATCH endpoint.
Testing Infrastructure
Throughout the week, the E2E test suite grew from basic smoke tests to 15+ test scenarios covering the full lifecycle chain, force deploys, env restrictions, scheduled deployments, approval gates, step retry, per-step timeout, re-deployments, and cross-project release validation. The CI config was updated to install bash in the Alpine-based runner before test execution (since the runner uses os/exec to invoke real bash scripts).
Architecture Decisions
A few design choices solidified over the week:
SQLite for persistence: The database remains a local file with WAL journal mode and busy timeout. No migration to Postgres is planned – the single-binary, zero-dependency deployment model is intentional. The modernc.org/sqlite driver (pure Go, no CGO) keeps cross-compilation trivial.
Single-binary runner: Deployment steps execute as bash scripts via os/exec.CommandContext with context-based timeouts. Logs stream through an in-memory broker to SSE endpoints. The runner tracks in-flight deployments via a map[int64]context.CancelFunc for cancellation support. No container runtime or daemon set is required.
HTMX over SPAs: All UI interactivity uses HTMX for partial page swaps, Alpine.js for local state (theme toggle, form reset on successful submission), and DaisyUI for component styling. No React, no build step beyond esbuild for the toast/theme JS bundle.
Immutable releases: Steps and variables are snapshotted into steps_json at release creation time. Later edits to steps do not retroactively affect existing releases. The Refresh Release endpoint explicitly re-snapshots from the current project state.
Looking Ahead
The durpdeploy feature set is approaching parity with a production deployment runner, but several gaps remain for the coming weeks:
-
Webhook notifications: The
features.mddocument (committed this week) lists deployment webhooks as high-value. AfterRun()finishes, POST a JSON payload to configured URLs. No new dependency needed – stdlibnet/httpcovers this. -
Deployment groups / rollback: A one-click rollback that deploys the previous successful release to the same environment. This is distinct from the re-run button (which re-runs the same release), and would require tracking the “previous known-good” for each environment.
-
Metrics and dashboards: The dashboard metrics introduced this week (deployments today, running count, latest per release+env) are a start, but a Prometheus metrics endpoint (
/metrics) with deployment duration histograms, step success rates, and queue depths would improve observability. -
Registry credential store: The variables system supports per-environment overrides, but there’s no built-in support for Docker registry credentials, SSH keys, or other deployment secrets that deployments commonly need. A future iteration could add a dedicated credentials table with scope restrictions.
-
Web UI for cron schedule management: The scheduled deployments feature works end-to-end via the API and E2E tests, but the web UI is minimal (list + enable/disable toggle). Adding a proper schedule creation form with cron expression validation would make it user-friendly.
The GitOps side was quiet this week – just the Hermes endpoint migration – but the underlying Flux reconciliation pipeline continues to run the DMZ environment without issues.