Decrypt age-encrypted .enc files to their plaintext siblings at deploy time so your orchestrator can read them: .env files, any other config, or a single file piped through stdin/stdout. Ciphertext stays tracked in git; plaintext is generated next to it and never committed.
Walks a mounted directory tree (or a single .enc file you name), finds every <name>.enc ciphertext source (binary or armored age format), and atomically writes its decrypted plaintext to the sibling <name>: apps/x/.env.enc becomes apps/x/.env. The source is never modified, so your working tree stays clean: git pull always applies rotated secrets, and the generated plaintext is just re-derived on the next pass. An --ext filter narrows a walk by the OUTPUT suffix (--ext .env selects .env.enc sources); a - target switches to a stdin-to-stdout pipe for a single file. Designed to run as a pre_deploy step before docker compose up reads the files.
The age-decrypt binary is a single static Go executable on gcr.io/distroless/static:nonroot:
decrypt --ext .env: decrypt every.env.encinAGE_REPO_ROOTto its.envsibling (the deploy use case)decrypt /path: decrypt a specific.encfile or every.encsource under a directory treedecrypt -: pipe, stdin ciphertext in, stdout plaintext outhealth: file-based health probe for DockerHEALTHCHECK
The decrypt subcommand always requires you to say what to decrypt (an extension filter, a path, or -). Server mode (no subcommand) is the always-on container entrypoint that idles as a docker exec target (see the Server mode note under Subcommands for details).
- Ciphertext and plaintext are separate planes:
<name>.encis tracked in git and never touched;<name>is generated and gitignored. Your compose file referencesapps/<x>/.envlike usual,git statusstays meaningful on live checkouts, and agit pullcan never conflict with a decrypted secret (the failure mode of in-place rewriting, which v2 used) - Fail-closed: a
.encsource that will not decrypt, an unreadable subtree, or stray age ciphertext sitting at a plaintext path (an un-migrated secret) all exit non-zero and block the deploy; ciphertext can never be silently consumed as config - Multi-identity: the key file may hold several identities (one per line); a file encrypted to any one of them decrypts, so key rotation is just adding the new key alongside the old
- Concurrency-safe: parallel invocations on the same stable tree won't collide, so simultaneous deploys are safe
- Atomic: a failed decrypt never leaves a half-written
.env, and a source can never be corrupted (it is opened read-only) - Scoped source reads: symlinks, hardlinks, FIFOs, devices, and directories are rejected as sources, and pathname resolution is confined to the mounted tree; under
--extany matching nonregular plaintext path fails the pass - Distroless + nonroot: minimal attack surface; no shell, no package manager, no extra binaries
- Per-file bounds: each encrypted input is capped at 10 MB and each decrypted output at 1 MB; plaintext is published mode 0600
- File-based health marker: works with Docker's no-shell distroless healthcheck (
HEALTHCHECK CMD ["/age-decrypt", "health"])
Available from both ghcr.io/cplieger/docker-age and docker.io/cplieger/docker-age (identical images and tags).
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.enc apps/myservice/.env
-
Commit
apps/myservice/.env.enc(encrypted, ASCII-armored) and gitignore the plaintext (echo 'apps/*/.env' >> .gitignoreor equivalent). The.envyou edit locally is exactly the file your apps read after decrypt. -
On each server, run
age-decryptas an always-on container (see the Server mode note below). Your deploy triggers a fresh pass before the stack starts withdocker exec age /age-decrypt decrypt --ext .env:
services:
age:
image: ghcr.io/cplieger/docker-age:latest
container_name: age
restart: unless-stopped # always-on: stays up between deploys as an exec target
environment:
# Required: path to the age identity file (one identity per line).
AGE_KEY_FILE: "/age/keys.txt"
# AGE_REPO_ROOT defaults to /repo (the tree `decrypt` walks when no path is
# given). Set it only to target a SUBDIRECTORY of /repo; see the note below
# on re-cloning orchestrators. A tree (or folder of many repos) mounted at
# /repo is fine as-is.
volumes:
- "/path/to/age-keys:/age:ro" # directory with the age identity (keys.txt, mode 0600)
- "/path/to/repo:/repo" # the tree to decrypt, or a folder containing many reposTrigger a decrypt pass on demand (no restart needed):
docker exec age /age-decrypt decrypt --ext .envRe-cloning orchestrators: if your deploy tool replaces the repo directory (a new inode) on each sync, a container mounting that directory sees a stale mount. Mount the stable parent at
/repoand setAGE_REPO_ROOT=/repo/<repo-name>so the walk re-resolves the child on every pass.
Or as a fire-and-forget one-shot before deploy (no long-running container):
docker run --rm \
-e AGE_KEY_FILE=/age/keys.txt \
-v $PWD/age-keys:/age:ro \
-v $PWD/repo:/repo \
ghcr.io/cplieger/docker-age:latest decrypt --ext .env| Variable | Description | Default |
|---|---|---|
AGE_KEY_FILE |
Absolute path to the age identity file (one identity per line; all are tried, so key rotation works) | required (example: /age/keys.txt) |
AGE_REPO_ROOT |
Absolute path to the tree decrypt walks when no target path is given |
/repo |
AGE_LOG_LEVEL |
Log level: debug/info/warn/error (case-insensitive); debug shows per-file skip reasons |
info |
| Mount | Description |
|---|---|
/age |
Directory containing your age identity (keys.txt, mode 0600). Mount read-only. |
/repo |
Repository tree of .enc sources; plaintext siblings are generated in it. |
/age-decrypt decrypt [--ext <suffix>]... [<path>...]
/age-decrypt decrypt -
/age-decrypt health
The decrypt subcommand requires at least one of: --ext, a target path, or -. Calling decrypt with no arguments is an error (nothing to do).
| Input | Behavior |
|---|---|
decrypt --ext .env |
Walk AGE_REPO_ROOT, decrypt every *.env.enc to its .env sibling |
decrypt --ext .env --ext .yaml |
Walk AGE_REPO_ROOT, decrypt *.env.enc OR *.yaml.enc sources |
decrypt --ext .env /path/to/dir |
Walk the given directory (not AGE_REPO_ROOT), same filter |
decrypt /path/to/file.env.enc |
Decrypt that one source to /path/to/file.env (explicit target must be .enc) |
decrypt /path/to/dir |
Walk that directory, decrypt all .enc sources (no filter) |
decrypt - |
Pipe: read ciphertext from stdin, write plaintext to stdout |
decrypt (bare, no args) |
Error (exit 1): you must specify what to decrypt |
health |
Read /tmp/.healthy marker: exit 0 if healthy, 1 if not |
--ext behavior:
- The filter names the decrypted OUTPUT suffix:
--ext .envselects.env.encsources and produces.envfiles. - The dot is auto-prefixed if missing (
--ext env=--ext .env). - Values ending in
.enc, containing/or\, or carrying surrounding whitespace are rejected instead of becoming silent no-op filters. - The same post-strip filter applies to an explicit file target:
decrypt --ext .env config.yaml.encskips that source because its output isconfig.yaml. --extcannot be combined with stdin (decrypt -) because a byte stream has no output filename to filter.- Under
--ext, non-.encpaths matching the suffix are also checked: regular plaintext there is the expected steady state (a generated output from a previous pass, or a committed plaintext config) and is skipped, while age ciphertext or a nonregular path at the plaintext name fails the pass. - Without
--ext, only.encfiles are considered and everything else is out of scope (a deliberately encrypted archive kept at rest never trips the guard).
Server mode (no subcommand, the container's PID 1 entrypoint): starts up, marks itself healthy, and idles. No startup decrypt: all decryption is triggered explicitly via docker exec age /age-decrypt decrypt --ext .env (or any other decrypt invocation). The container stays alive as a long-lived exec target; the health marker is always healthy while the process is running. Use restart: unless-stopped in compose so it recovers from OOM/crashes.
Each .enc source is inspected by its first bytes:
- Armored age (
-----BEGIN AGE ENCRYPTED FILE-----): decrypted viaage/armor - Binary age (
age-encryption.org/v1): decrypted directly - Anything else: a failure (exit non-zero). A
.encfile that is not age ciphertext means a broken encrypt workflow, and silently passing it through would hide that
Mixing encrypted and plaintext files in the same tree is fine: plaintext lives at the plain name, ciphertext at the .enc name. Re-running decrypt is idempotent in outcome; every pass re-derives the same plaintext siblings from the same sources (a rotated .enc simply produces the new plaintext on the next pass).
Two source names are rejected up front, before any decryption: a bare .enc (no output name) and a double-suffixed <x>.enc.enc (its output would itself look like a ciphertext source and poison the next pass).
v2 rewrote ciphertext files in place (apps/x/.env was tracked ciphertext that became plaintext on the server). v3 flips the layout: ciphertext moves to apps/x/.env.enc and the plaintext .env is generated. To migrate a repo:
- Rename every tracked ciphertext file:
git mv apps/x/.env apps/x/.env.enc(repeat per file; plaintext configs that were never encrypted stay put). - Gitignore the generated plaintext paths (for example
apps/*/.env). If an output path is still tracked, v3 deliberately overwrites it with the decrypted bytes and leaves the checkout dirty; remove migrated secret outputs from the index rather than relying on the ignore rule alone. - Remove any deploy script that restores or resets the old tracked plaintext path (for example
git restore -- 'apps/*/.env'). After the rename there is nothing tracked at that path, and underset -esuch a command can abort before pull/decrypt. - Deploy the v3 image before the renamed tree reaches the servers (an old v2 binary finds no
.envciphertext after the rename and decrypts nothing; a v3 binary on a pre-rename tree fails loudly on the stray ciphertext under--ext). - Keep the trigger command unchanged:
--ext .envselects.env.encsources in v3.
The stray-ciphertext guard is the migration net: after step 3, any secret you forgot to rename fails the deploy with a stray age ciphertext error naming the file, instead of letting an app read ciphertext.
age-decrypt health reads /tmp/.healthy. In server mode the marker is set healthy the moment the container starts, stays healthy as long as the process is alive, and is removed on shutdown. The marker reflects process liveness (it lets Docker detect and restart a crashed server), not decrypt outcome. A decrypt invocation does not touch the marker; its success or failure is reported by its exit code (non-zero on any failure, including an unreadable repo root). That non-zero exit is the deploy-blocking signal: wire your pre_deploy step to fail on it. The baked healthcheck targets the long-running server, so the always-on setup above uses it as-is. If you instead run a one-shot decrypt container (the docker run --rm form above), disable the healthcheck (healthcheck: {disable: true} in compose); the one-shot exits without ever running the server that writes the marker. 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. The image runs as the distroless non-root user by default; keep the identity mode 0600 on the host and readable by that user. - The container needs read access to the
.encsources and write access to the directories holding them (the plaintext sibling is created via an atomic temp-then-rename in the same directory). Generated plaintext is written mode 0600, owned by the container user. Run it as a user that owns the tree, or fix ownership on the mounts. If the tree has mixed or root ownership (for example an orchestrator that clones it as root), override withuser: "0:0".
A decrypt pass supports concurrent age-decrypt invocations on the same stable checkout. It is not a defense against an untrusted process that can rename, hardlink, or replace files during the pass; do not grant untrusted writers access to the mounted tree while decryption runs.
Paths ending .age-decrypt-tmp are reserved for the decryptor's plaintext temps, and a pass may clean up stale files matching that pattern; do not create application files in this namespace.
The 10 MB ciphertext and 1 MB plaintext limits are per file. A pass has no aggregate file-count, total-byte, or wall-clock budget, so bound repository size and invocation frequency at the deployment layer.
The tool fails closed: non-age .enc content, stray ciphertext at a plaintext path, and symlink, hardlink, or otherwise nonregular sources all reject with a non-zero exit, and pathname resolution is confined to the mounted tree so a link cannot pull ciphertext in from outside it. The image runs as a non-root user on a distroless base (no shell, no package manager). Live scan results are on the repository's Security tab.
The image is published with cosign signatures and SBOM attestations.
All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.
| Dependency | Source |
|---|---|
| golang (builder) | Docker Hub |
| distroless/static | GoogleContainerTools |
| filippo.io/age | 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 project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
Apache-2.0. See LICENSE.