Decrypt age-encrypted .env files at deploy time so your orchestrator can read them as plaintext.
Walks a mounted directory tree, finds every .env file that's age-encrypted (binary or armored), and rewrites it in place with its decrypted plaintext. Files that aren't age-encrypted are left untouched. Designed to run as a pre_deploy step before docker compose up reads the .env files.
The age-decrypt binary is a single static Go executable on gcr.io/distroless/static-debian13:nonroot. It supports two subcommands:
decrypt— walk the tree, decrypt every age-encrypted.env, exit 0 on successhealth— file-based health probe (writes/tmp/.healthyon successful decrypt; reads it back to report status)
- In-place rewrites — your compose file references
apps/<x>/.envlike usual; no separate plaintext path to track - Concurrency-safe — multiple parallel invocations on the same tree won't collide. Tmp files are named with PID + a process-local atomic counter, and an orphan-tmp sweep with an age-bound threshold preserves in-flight peer writes
- Atomic — write-temp-then-rename so a failed decrypt never leaves a half-written
.env - Symlink-safe — uses
os.OpenRootto confine all I/O to the mounted tree (no escape via symlinks) - Distroless + nonroot — minimal attack surface; no shell, no package manager, no extra binaries
- Bounded memory — encrypted files capped at 10 MB, decrypted output capped at 1 MB (defense against decompression bombs and runaway inputs)
- File-based health marker — works with Docker's no-shell distroless healthcheck (
HEALTHCHECK CMD ["/age-decrypt", "health"])
The expected workflow is encryption-at-rest in git, decryption at deploy:
-
Encrypt your
.envfiles locally:age -a -R recipients.txt -o apps/myservice/.env apps/myservice/.env.dec
-
Commit
apps/myservice/.env(encrypted, ASCII-armored) to git..env.decstays local. -
On each server, run
age-decryptas a pre-deploy step before your stack starts:
services:
age:
image: ghcr.io/cplieger/docker-age:latest
container_name: age
command: ["decrypt"]
user: "0:0" # required for repo write access; see below
environment:
AGE_KEY_FILE: "/age/keys.txt"
AGE_REPO_ROOT: "/repo"
volumes:
- ./age-keys:/age:ro # directory with the age identity (keys.txt, mode 0600)
- ./repo:/repo # tree containing the *.env files to decryptOr as a one-shot before deploy:
docker run --rm \
-e AGE_KEY_FILE=/age/keys.txt \
-e AGE_REPO_ROOT=/repo \
-v $PWD/age-keys:/age:ro \
-v $PWD/repo:/repo \
ghcr.io/cplieger/docker-age:latest decrypt| Variable | Description | Default |
|---|---|---|
AGE_KEY_FILE |
Absolute path to the age identity file (one identity per line) | /age/keys.txt |
AGE_REPO_ROOT |
Absolute path to the tree to walk for .env files |
/repo |
| Mount | Description |
|---|---|
/age |
Directory containing your age identity (keys.txt, mode 0600). Mount read-only. |
/repo |
Repository tree containing .env files to decrypt in place. |
| Command | Description |
|---|---|
decrypt |
Walk AGE_REPO_ROOT, decrypt every age-encrypted .env in place, exit 0 on success |
health |
Read the /tmp/.healthy marker — exit 0 if healthy, 1 if not. For Docker HEALTHCHECK. |
.env files are inspected by their first bytes:
- Armored age (
-----BEGIN AGE ENCRYPTED FILE-----) — decrypted viaage/armor - Binary age (
age-encryption.org/v1) — decrypted directly - Anything else — treated as already-plaintext and skipped silently
This means you can mix encrypted and plaintext .env files in the same tree, and re-running decrypt is idempotent (a previously-decrypted file will be skipped on the next pass).
age-decrypt health reads /tmp/.healthy. The marker is written when the most recent decrypt run completed successfully, and removed if a run failed. The standard distroless HEALTHCHECK uses CMD form (no shell needed):
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \
CMD ["/age-decrypt", "health"]- The age identity file (
keys.txt) must be readable by the container user — typically root inside the container, mode 0600 on the host - The container needs write access to every
.envit decrypts — if your repo is owned by a non-root host user (typical homelab setup), the safest pattern is to run the container as root with the repo bind-mounted read-write, then let the host's directory ownership resume after deploy
| Tool | Result |
|---|---|
| govulncheck | No vulnerabilities |
| golangci-lint | Clean (default: standard preset incl. govet + staticcheck) |
| hadolint | Clean |
| trivy | 0 dependency CVEs (distroless base only) |
| grype | 0 dependency CVEs (distroless base only) |
| gitleaks | No secrets detected |
| CodeQL | No findings |
The image is published with cosign signatures and SBOM attestations.
The Go binary is built with -trimpath (strip absolute paths) and -ldflags="-s -w" (strip symbol tables and DWARF). All file I/O goes through os.OpenRoot to prevent symlink traversal out of the mounted tree.
- Comprehensive test suite: unit tests, property-based tests, fuzz tests, and benchmarks
- Concurrency tests reproduce the in-process race seen with parallel orchestrator deploys (e.g. multiple stacks calling
docker exec age /age-decrypt decryptsimultaneously) — the test verifies that PID-keyed tmp names + age-bound orphan sweep prevent collisions - Health probe tests verify the marker lifecycle (write on success, remove on failure)
All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.
| Dependency | Version | Source |
|---|---|---|
| golang (builder) | 1.26-alpine |
Docker Hub |
| distroless/static-debian13 | nonroot |
GoogleContainerTools |
| filippo.io/age | latest | GitHub |
This project packages age (the encryption library by @FiloSottile) into a deploy-time decryption tool. All credit for the core encryption work goes to the upstream maintainers.
Issues and pull requests are welcome. Please open an issue first for larger changes so the approach can be discussed before implementation.
This image is built with care and follows security best practices, but it is intended for homelab use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
This project is licensed under the GNU General Public License v3.0.