Skip to content

Contributing

This page covers the development workflow for maintaining this repository: dependency management, commit conventions, and the release process.

Generating Random SOPS Secrets

For app-owned passwords, passphrases, and tokens, set the initial dotenv value to the literal GENERATE and encrypt the template. Keep user-supplied values such as OAuth or SMTP credentials out of the helper arguments, and reference existing shared secrets rather than generating duplicates.

The variables must already exist in services/<app>/secret.sops.env, and that dotenv file must already be SOPS-encrypted. With a usable age key configured, run:

bash scripts/generate-sops-secrets.sh services/<app>/secret.sops.env VARIABLE=BYTE_COUNT [VARIABLE=BYTE_COUNT ...]

The helper is generate-once bootstrap only. It generates a cryptographically secure hexadecimal value only when a requested variable exists and its decrypted value is exactly GENERATE. Every existing non-sentinel value is preserved. When nothing needs generation, it exits successfully without rewriting the file.

Do not run the helper concurrently against the same encrypted file. Rotate existing values manually:

sops edit services/<app>/secret.sops.env

Never commit CHANGE_ME placeholders for generated secrets.

Use SOPS-native SOPS_AGE_KEY_CMD so the private key remains in 1Password:

  1. Install the 1Password CLI (op) for the local platform.
  2. In 1Password Desktop, enable Settings > Developer > Integrate with 1Password CLI.
  3. Sign in to 1Password Desktop, unlock the app, and approve any CLI authorization prompt.
  4. Verify that op is available and authenticated without printing any secret:
command -v op >/dev/null
op account list >/dev/null
op whoami >/dev/null
  1. Configure the secret reference in the current shell. Every angle-bracket component is a placeholder that must be replaced:
export SOPS_AGE_KEY_CMD='op read "op://<vault>/<item>/<field>"'

SOPS invokes this command internally when it needs the key. Do not invoke or shell-evaluate SOPS_AGE_KEY_CMD yourself: SOPS tokenizes it and directly executes the resulting argument vector, while a shell would interpret metacharacters differently. Never print or log the command output. Keep the 1Password item and field narrowly targeted to the required Age key.

Before generating or editing, verify that the selected identity can decrypt the target while discarding stdout:

target='services/<app>/secret.sops.env'
mise exec -- sops decrypt --input-type dotenv --output-type dotenv "${target}" >/dev/null

A normal multiline Age identity file is accepted: comment lines may surround one supported identity. A password field flattened into one line beginning with # is invalid because the private key becomes part of the comment. Store only the AGE-SECRET-KEY-... line or preserve real newlines in a Secure Note.

Alternatively, create a local sops.op.env file (the *.op.env pattern is gitignored) containing only an SOPS_AGE_KEY=op://<vault>/<item>/<field> reference, then inject it for one command:

op run --env-file sops.op.env -- bash scripts/generate-sops-secrets.sh services/<app>/secret.sops.env VARIABLE=BYTE_COUNT

If the key cannot be retrieved or used to decrypt the template, the helper stops with actionable guidance.

Safe human review

Open the encrypted target through SOPS with VS Code:

target='services/<app>/secret.sops.env'
SOPS_EDITOR='code --wait' mise exec -- sops edit "${target}"

Replace remaining sentinels and required user-supplied or shared values in the editor. Save and close the file; SOPS then re-encrypts it. Never redirect decrypted output to a plaintext file.

Check encryption status, reject unresolved sentinels without printing plaintext, review the Git status, scan the working tree for leaks, and confirm that no generated temporary files remain:

status="$(mise exec -- sops filestatus "${target}")"
printf '%s\n' "${status}" | grep -Eq '"encrypted"[[:space:]]*:[[:space:]]*true' || {
    printf '%s\n' 'ERROR: target is not SOPS-encrypted' >&2
    exit 1
}
unset status

set +e
mise exec -- sops decrypt --input-type dotenv --output-type dotenv "${target}" |
    awk -F= '$2 == "GENERATE" || $2 == "CHANGE_ME" { found = 1 } END { exit found ? 42 : 0 }'
pipeline_status=("${PIPESTATUS[@]}")
set -e
if ((pipeline_status[0] != 0)); then
    printf '%s\n' 'ERROR: target cannot be decrypted' >&2
    exit 1
fi
if ((pipeline_status[1] == 42)); then
    printf '%s\n' 'ERROR: unresolved secret sentinel' >&2
    exit 1
fi
if ((pipeline_status[1] != 0)); then
    printf '%s\n' 'ERROR: sentinel validation failed' >&2
    exit 1
fi
unset pipeline_status

git status --short -- "${target}"
mise exec -- gitleaks dir --redact .
leftover="$(
    find "$(dirname "${target}")" -maxdepth 1 -type f \
        -name "$(basename "${target}").generated.*" -print -quit
)"
test -z "${leftover}" || {
    printf 'ERROR: generated temporary file remains: %s\n' "${leftover}" >&2
    exit 1
}
unset leftover

Optional key-file fallback

Use SOPS_AGE_KEY_FILE only when 1Password CLI is unavailable. Without that variable, SOPS checks %APPDATA%\sops\age\keys.txt on Windows or ${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt on Linux/WSL. Do not print, log, or read the private key through scripts.

For an already provisioned Windows fallback, restrict the key file ACL to the current user:

$keyFile = if ($env:SOPS_AGE_KEY_FILE) {
    $env:SOPS_AGE_KEY_FILE
} else {
    Join-Path $env:APPDATA 'sops\age\keys.txt'
}
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$acl = New-Object System.Security.AccessControl.FileSecurity
$acl.SetAccessRuleProtection($true, $false)
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'Allow')
$acl.AddAccessRule($rule)
Set-Acl -Path $keyFile -AclObject $acl

For an already provisioned Linux/WSL fallback, restrict the key file to mode 600:

key_file="${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}"
chmod 600 "$key_file"

Renovate

Dependency updates are managed by Renovate. Only renovate.json5 lives in this repository — every other file below is a remote preset in DevSecNinja/.github, pulled in through the extends list. There is no .renovate/ directory here.

File Purpose
renovate.json5 Root config — global settings, extends index, repo-local creation schedule, concurrency cap and digest exceptions
github>DevSecNinja/.github//.renovate/autoMerge.json5 Auto-merge policy (pin, pinDigest, digest, minor, patch)
github>DevSecNinja/.github//.renovate/base.json5 Shared baseline settings common to all DevSecNinja repositories
github>DevSecNinja/.github//.renovate/customManagers.json5 Regex managers for SOPS version, mise min_version, workflow versions
github>DevSecNinja/.github//.renovate/groups.json5 Grouped updates (postgres, mise)
github>DevSecNinja/.github//.renovate/labels.json5 PR labels by update type and datasource
github>DevSecNinja/.github//.renovate/packageRules.json5 Release age gates, non-Docker-Hub registry gating, stale flag, linuxserver versioning
github>DevSecNinja/.github//.renovate/semanticCommits.json5 Scoped commit messages with version arrows

Update timing policy

The root-level schedule: ["at any time"] in renovate.json5 overrides the shared base.json5 creation window of ["every weekend", "on Friday"] in timezone Europe/Amsterdam. Eligible update candidates may be created on normal hosted Renovate runs any day. Hosted run frequency is unchanged.

The repository-local capacity change sets root-level prConcurrentLimit: 20. branchConcurrentLimit remains unset and inherits prConcurrentLimit, raising the effective branch/PR cap from the default 10 to 20. More cooldown candidates can start soaking concurrently, but prHourlyLimit remains at its inherited default of 2: creation is still limited to two PRs per hour, subject to hosted run frequency and queue bottlenecks.

This is a capacity-only change: the exact five-package rebaseWhen: "never" frozen cohort, grouping, major manual merge policy, and all automerge/digest policies remain unchanged. Increased capacity does not bypass age gates or required checks, fix existing failing checks, reset existing HEADs, or guarantee immediate clearance of the entire backlog.

Creation timing is separate from merge eligibility. Native minimumReleaseAge checks remain in place where configured and trusted release timestamps are available. Independently, the required pr-cooldown check on renovate/docker-gated-* branches still waits 14 days from the HEAD committer time. The age policies are:

Update type Manager / datasource Minimum age Merge
minor / patch actions/* GitHub Actions 3 days Auto-merged
minor / patch All other GitHub Actions 14 days Auto-merged
minor / patch Docker images 14 days Auto-merged
minor / patch GitHub Releases 14 days Auto-merged
minor / patch mise tools 14 days Auto-merged
digest Docker images pinned to :latest / :beta or explicitly matched rolling tags 0 (native; see below) Auto-merged
digest GitHub Actions refs pinned to main 14 days Auto-merged
major Everything 14 days Manual merge

The existing Docker mutable-channel and rolling-tag digest rules set minimumReleaseAge: "0". Docker Hub digest updates covered by these rules are outside the Docker-gated scope and can flow sooner, subject to other required checks and queue limits; not every update waits 14 days. This native age exception does not bypass pr-cooldown on Docker-gated branches.

Auto-merged PRs require CI to pass and carry the [automerge] commit-message suffix. Most rules use automergeType: "pr" with platformAutomerge: true, so GitHub merges the PR itself once every required check is green, regardless of the creation window; Renovate's automergeSchedule is not enforced with platform automerge. The 3-day actions/* exception uses automergeType: "branch" (direct push, no PR). Major updates always require a manual merge, regardless of datasource.

Digest-only updates are disabled by the shared preset to reduce PR noise and to avoid auto-merging a hijacked mutable tag. This repository re-enables them in renovate.json5 for mutable Docker channels (:latest / :beta), explicitly matched rolling Docker version tags, and GitHub Actions / reusable-workflow refs pinned to main (#636).

Enforcing the soak without a trusted timestamp

Renovate only derives a release timestamp for Docker images from Docker Hub — it reads tag_last_pushed from the hub.docker.com API, which is Hub-specific and not part of the OCI specification. The standard Registry V2 /tags/list endpoint every other registry serves returns tag names only, and Renovate will not fall back to the OCI org.opencontainers.image.created label because the publisher controls it and could forge it to skip the soak.

The 14-day soak is still enforced for those images — just by a different mechanism:

  1. A rule in packageRules.json5 matches Docker dependencies whose package name carries an explicit registry host other than Docker Hub (/^[^/]*\./ combined with !/^docker\.io\//).
  2. That rule sets minimumReleaseAgeBehaviour: "timestamp-optional" — so Renovate can open the PR on a normal hosted run instead of blocking on a timestamp it cannot obtain — and stamps the branch via additionalBranchPrefix: "docker-gated-".
  3. .github/workflows/renovate-pr-cooldown.yml calls the shared reusable workflow, which posts the required pr-cooldown status check on those renovate/docker-gated-* branches. The check stays pending until the PR branch HEAD committer date is at least 14 days old, with scheduled reevaluation every six hours. Eligible PRs can then merge via GitHub auto-merge once all required checks pass; major updates still require manual merge.

Because the same rule sets both the behaviour and the branch prefix, and the workflow gates exactly that prefix, the Renovate config and the gate cannot drift apart.

Two consequences worth knowing:

  • There is no registry allowlist (removed in DevSecNinja/.github PR #312). Any registry nobody has explicitly configured is gated by default, which is fail-safe. Under the previous allowlist a new registry silently got nothing — dhi.io was missing for roughly 3.5 months and those images received no updates at all, including security updates (#634).
  • Bare Docker Hub names (e.g. nginx, library/nginx) use native release-age rules and are not Docker-gated. This repository mandates an explicit registry prefix on every image, so bare names should not appear here anyway.

Background: ADR 0005 in DevSecNinja/.github (docs/design-decisions/0005-pr-age-cooldown-for-untrusted-timestamps.md), which classifies pr-cooldown as a load-bearing control. For why docker.io is preferred when the same image is available on several registries, see Architecture § Image Selection: Registry Preference.

Homepage frozen-candidate pilot

The Homepage pilot now covers an exact five-package cohort, expanded in #793. The local renovate.json5 rule Freeze Homepage, Traefik, Immich app, and Home Assistant update candidates while they complete the cooldown sets rebaseWhen: "never" for datasource docker, overriding the shared DevSecNinja/.github setting rebaseWhen: "conflicted" only for:

  • Home Assistant: ghcr.io/home-assistant/home-assistant — churn on the existing :beta channel is intentionally included as a low-impact rolling-digest stress test because the container is not actively used.
  • Homepage: ghcr.io/gethomepage/homepage — single-image update coverage, continuing the original pilot.
  • Immich: ghcr.io/immich-app/immich-machine-learning and ghcr.io/immich-app/immich-server — grouped app-image coverage, preserving the existing Immich update group.
  • Traefik: dhi.io/traefik — single-image update coverage for important infrastructure.

Matching is by exact package name, not by stack or registry. Immich Postgres (ghcr.io/immich-app/postgres), dhi.io/redis, all other sidecars, AdGuard Home, Bitwarden, and ESPHome are excluded. The aim is to let existing candidates finish their soak instead of repeatedly restarting it as newer candidates appear.

  • Existing branch: Ordinary automatic Renovate version/digest rewrites to the same existing branch stop. This freezes that branch's candidate; it is not a per-digest queue. Separate major-update branches and stale-branch cleanup remain normal.
  • Cooldown: The independent required pr-cooldown check on renovate/docker-gated-* branches still waits 14 days from the HEAD committer date and reevaluates every six hours. Expanding the rule does not rewrite existing HEADs or reset their elapsed age; current candidates retain time already accrued.
  • Mutable channels: Digest updates for :latest / :beta remain enabled with minimumReleaseAge: "0". That existing Renovate setting is unchanged and does not bypass the independent Docker-gated cooldown.
  • Merge: GitHub platformAutomerge is already enabled. Non-strict up-to-date protection allows an eligible PR that is behind the base branch but has no conflicts to merge once all required checks pass, without rebasing merely to catch up.
  • Next candidate: After merge, a normal hosted Renovate run may propose the latest candidate on any day, subject to the rate/concurrency limits described above and queue bottlenecks. The new candidate starts a fresh soak. Intermediate versions/digests are not queued individually.

Operator intervention: Conflicts, failing builds, and known-bad releases need an operator; the frozen branch will not automatically repair them. Disable automerge for a known-bad candidate before diagnosing it so a passing cooldown cannot cause an unwanted merge. In particular, Traefik is important infrastructure: retain manual rejection or refresh when a candidate is known-bad or a newer release contains a needed security fix. This policy does not force unsafe candidates to finish soaking or merge.

Rebasing can replace the candidate and restart the cooldown

Resolving conflicts manually or requesting a Renovate rebase from the PR or Dependency Dashboard, including Rebase all open PRs, can refresh the candidate and reset its HEAD age. Even a manual Git rebase that preserves the image target restarts this gate by writing a new committer date. Rebase deliberately, then review the resulting target and required checks; automatic conflict resolution is not promised.

Observed first cycle: Homepage #722 retained HEAD 6d3dfe8 (2026-09-10 02:32:56 UTC) and candidate v2.3.0@sha256:f820276654539cdc2cf0169f28188d135919a7984fad76d83d8d5ff1383f3705 through the newer v2.4.0 release on 2026-09-17. After 55 pending cooldown status posts, the check succeeded on 2026-09-24 at 04:48:54 UTC and automerge followed at 04:49:22 UTC. This demonstrates one freeze/soak/automerge cycle. As of 2026-09-28, the next v2.4.0 candidate was only Rate-Limited in dashboard #115; no next PR or new soak had been observed.

Scope and rollback: The original frozen-cohort expansion was repository-local, not a global or shared-config change. It included no workflow, cooldown algorithm/duration, branch naming, grouping, automerge, rate-limit, digest-enabling, or image-pin changes. To roll back the freeze, remove the local freeze rule from renovate.json5; all five packages then inherit the shared rebaseWhen: "conflicted" behavior again.

Broader rollout tracking remains open in #758; related: #759.

References: Renovate rebaseWhen and renovatebot/renovate#26294.

Silently skipped dependencies (dhi.io)

Docker Hardened Images prune entire minor lines. When the tag a compose file is pinned to stops existing, getCurrentVersion returns null (the shared preset uses rangeStrategy: "pin") and Renovate marks the dependency skipReason: invalid-value. The dependency dashboard filters skipped dependencies out, so the image disappears from the dashboard with no warning and silently stops receiving updates. This is how four hardened images went roughly 3.5 months without any updates (#634).

Symptom

A dhi.io image that used to appear on the dependency dashboard and no longer does is not up to date — it is unparseable. Check the catalog and re-pin the compose file to a tag that still exists.

Rule precedence note

packageRules are applied in the order they appear across all extends entries — last matching rule wins for each property. autoMerge.json5 is loaded before packageRules.json5, so packageRules.json5 must not contain a matchManagers: ["github-actions"] timing rule or it would override the 3-day exception for actions/*.

Commit Message Convention

All commits follow the Conventional Commits specification:

<type>(<scope>): <description>

Common types: feat, fix, chore, docs, refactor, ci. The scope is typically the service folder name (e.g. feat(immich):, fix(traefik):). Compliance is enforced locally by a lefthook commit-msg hook using cog verify.

Release Process

Releases are version-tagged on main and automatically published as GitHub Releases via a CI workflow.

Creating a release

# Bump the minor version (updates CHANGELOG.md, commits, tags, and pushes)
cog bump --minor

# Or patch for bug-fix releases
cog bump --patch

# Dry-run to preview the next version without making changes
cog bump --minor --dry-run

cog bump orchestrates the full release:

  1. Calculates the next semver version from conventional commits since the previous tag
  2. Runs git-cliff --tag <version> --output CHANGELOG.md to regenerate the full changelog
  3. Runs dprint fmt CHANGELOG.md to ensure the changelog passes CI formatting checks
  4. Creates a chore(release): bump version to <version> commit containing the changelog update
  5. Creates the v<version> git tag
  6. Pushes the commit and tag to origin

The tag push triggers .github/workflows/release.yml, which runs git-cliff --latest --strip all to produce release-scoped notes and creates the GitHub Release automatically.

Tools

Tool Role
cog Version bump, bump commit, git tag, push orchestration
git-cliff Changelog generation (CHANGELOG.md + GitHub Release notes)
cliff.toml Commit grouping, body template, GitHub commit link configuration
cog.toml Bump hooks, tag prefix, merge-commit filtering

Task Runner

go-task provides a unified interface for all repository tasks — testing, linting, formatting, compose validation, deployment, and more. It is managed by mise alongside the other tools.

Verify the installation:

task --version

List all available tasks:

task --list

Common workflows:

Command What it does
task install Install all dependencies (mise tools + BATS libraries)
task test Run the full test suite
task lint Run all linters (YAML, shell, actions, security)
task format Auto-format all files (YAML, shell, Markdown)
task format:check Check formatting without modifying files
task ci:local Run the full CI pipeline locally (format check + lint + compose validate + test)
task ci:quick Quick checks — format and lint only, no tests
task compose:validate Validate all compose files
task pre-commit Run lefthook pre-commit hooks
task docs:serve Live-preview the MkDocs site locally

Run task help for detailed usage examples.

Testing

The repository has a BATS suite for scripts/dccd.sh and the SOPS random-secret helper, with 214 tests:

Category Count What it tests
Unit 142 DCCD functions and 21 mocked SOPS secret-helper tests
Integration 72 DCCD workflows and three integration tests using real Age and SOPS
E2E 4 Real Docker containers for DCCD — skipped locally and run separately

Running tests

task test
task test:unit
task test:integration
task test:e2e
task test:file -- tests/dccd/unit/log_message.bats

E2E tests require Docker and are skipped unless DCCD_E2E=1 is set. The task test:e2e command sets this automatically.

Pre-commit hook

From the repository root, install or regenerate Git hooks after changes to the launcher in .lefthook.toml:

mise install && mise exec -- lefthook install -f

Generated hooks finish mise install before executing the pinned Lefthook through mise exec; checks still run in parallel. If tool installation fails, the checks do not start and the commit is blocked. Ensure mise is on PATH in the environment that launches Git, including VS Code or other GUI clients.

This ordering prevents parallel checks within the same commit from racing to auto-install tools or repair mise runtime symlinks. It does not serialize independent processes or bypass sandbox restrictions.

For manual checks, use task pre-commit, which performs setup before running Lefthook, or run:

mise install && mise exec -- lefthook run pre-commit

Direct lefthook run invocations bypass the generated hook launcher and its setup step.

Lefthook runs both the DCCD and SOPS secret unit suites before every commit. CI and the Taskfile also include both projects' unit and integration tests; DCCD E2E tests run separately with Docker.

The SOPS integration suite creates a fresh temporary Age identity for every test, encrypts a fixture, runs generate-once replacement through an offline fake op, decrypts and verifies the result, then reruns the helper to prove no-op idempotency. Failure paths prove the encrypted SHA-256 is unchanged and leave no generated temporary files. CI never uses a production Age key.

Writing tests

See tests/README.md for the full test writing guide — directory structure, helper reference, mock patterns, and conventions.

Per-Service Documentation

Each service can have a README.md in its directory (e.g. services/adguard/README.md). These files are the source of truth for service-specific documentation — architecture, access URLs, init containers, secrets, first-run setup, and upgrade notes.

MkDocs can only serve files inside its docs/ directory. To make service READMEs appear in the MkDocs site without duplicating content, the repo uses symlinks:

docs/services/adguard.md → ../../services/adguard/README.md
docs/services/plex.md    → ../../services/plex/README.md

A script generates and maintains these symlinks:

bash scripts/generate-docs-symlinks.sh

When to run it:

  • After adding a new service with a README.md
  • After retiring a service (stale symlinks are cleaned up automatically)

The symlinks are committed to Git. Git stores them as text files containing the relative target path, so they work across clones on all platforms that support symlinks.

Because MkDocs resolves links relative to docs/services/ (the symlink location), cross-references to other docs must use paths relative to that directory — not relative to services/<app>/. For example, use [Infrastructure](../INFRASTRUCTURE.md) (not ../../docs/INFRASTRUCTURE.md). These links work in MkDocs strict mode; on GitHub the README content is still readable even though the relative link won't resolve from the services/<app>/ path.

Adding a new service to MkDocs

  1. Create services/<app>/README.md
  2. Run bash scripts/generate-docs-symlinks.sh
  3. Add the entry to the Services: section in mkdocs.yml (alphabetical order by display name):
- Services:
    - AdGuard Home: services/adguard.md
    - New App: services/new-app.md
  1. Commit the symlink and mkdocs.yml change together