registry
Private image pull credentials, host-wide.
registry declares credentials for a private container registry. Voodu rewrites $VOODU_ROOT/docker/config.json atomically on every apply, so any subsequent docker pull uses them.
Unlike most kinds, registry:
- Takes one label — the registry shortname (
"ghcr","ecr","gitlab"). - Is host-wide and unscoped — not scoped per app.
- Has no
env_fromfield — credentials must come from shell env.
Synopsis
registry "ghcr" {
url = "ghcr.io"
username = "${GHCR_USER}"
token = "${GHCR_TOKEN}"
}password = "..." is accepted as an alias for token.
Required fields
| Field | Type | Meaning |
|---|---|---|
url | string | Bare host (no scheme). E.g. "ghcr.io", "registry.gitlab.com", "123456789012.dkr.ecr.us-east-1.amazonaws.com". |
username | string | Registry account. |
token or password | string | Token / PAT. At least one is required; token wins if both are set. |
Each missing field is rejected with a per-field error:
registry/ghcr: url is required
registry/ghcr: username is required
registry/ghcr: token (or password) is requiredValidation
- Exactly one label.
registry "x" "y" {}is rejected — registry is host-scope, never(scope, name). (kind=registry, scope="", name=<label>)must be unique across the apply. Tworegistry "ghcr"blocks → reject.
Examples
GitHub Container Registry
registry "ghcr" {
url = "ghcr.io"
username = "${GHCR_USER}"
token = "${GHCR_TOKEN}"
}Then in your shell (e.g. via direnv):
export GHCR_USER=my-bot
export GHCR_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
voodu apply -f voodu -r prod-1Multiple registries on one host
registry "ghcr" {
url = "ghcr.io"
username = "${GHCR_USER}"
token = "${GHCR_TOKEN}"
}
registry "ecr" {
url = "123456789012.dkr.ecr.us-east-1.amazonaws.com"
username = "AWS"
token = "${ECR_TOKEN}"
}
registry "gitlab" {
url = "registry.gitlab.com"
username = "${GITLAB_USER}"
password = "${GITLAB_DEPLOY_TOKEN}"
}Each block writes its own entry in $VOODU_ROOT/docker/config.json.
Token rotation via cronjob (ECR)
ECR tokens expire every 12 hours. One pattern:
cronjob "infra" "ecr-refresh" {
schedule = "0 */6 * * *"
image = "amazon/aws-cli:latest"
env_from = ["aws/cli"]
command = [
"sh", "-c",
"aws ecr get-login-password --region us-east-1 | voodu config aws/ecr set ECR_TOKEN=$(cat -)"
]
}Then your registry block reads ${ECR_TOKEN} from shell env at next apply.
Where the credentials live
Voodu writes $VOODU_ROOT/docker/config.json (default:
/opt/voodu/docker/config.json) — not ~/.docker/config.json —
and exports DOCKER_CONFIG to every docker process it forks, so the
file it writes is the file the docker CLI reads.
The reason is the controller's systemd unit:
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=${VOODU_ROOT}ProtectHome=yes makes /home and /root empty and inaccessible for
the entire service cgroup — the forked docker processes included. So
~/.docker/config.json is both unwritable by the reconciler and
unreadable by the pull it is meant to authenticate. $VOODU_ROOT is in
ReadWritePaths, which makes it the one location that works with the
hardening left intact.
This is why docker login in an interactive shell does not fix a
failing deployment pull. That writes the credential into your home
directory; the controller runs as root under the unit above and never
sees it. The symptom is a pull that works when you type it and fails
when voodu runs it:
pull access denied, repository does not exist or may require
authorization: authorization failed: no basic auth credentialsDeclare a registry block instead — it is the only mechanism that
puts the credential where the controller can read it.
The controller logs the path it settled on at boot:
docker config at /opt/voodu/dockerPermissions and the docker group
Two processes need these credentials, and they do not run as the same
user. The controller is root under systemd. voodu receive-pack — the
far end of a build-mode deploy, which pulls the Dockerfile's FROM
image — runs as whatever user your SSH remote is configured with,
because no sane server allows SSH as root.
So the tree is owned by root and shared with the docker group:
drwxr-x--- root:docker /opt/voodu/docker/
-rw-r----- root:docker /opt/voodu/docker/config.json
drwxrwx--- root:docker /opt/voodu/docker/ecr-cache/
drwx------ <uid> /opt/voodu/docker/ecr-cache/<uid>/No world bits anywhere. Sharing with the docker group grants nothing
new: its members can already run docker run -v /:/host --privileged
and read every byte on the host, which is why Docker's own
documentation treats the group as root-equivalent. Excluding them
would buy no security and break every build-mode deploy — and the
build user is necessarily in that group already, or it could not run
docker at all.
The ECR helper's cache is per-uid because the helper writes it
0600. One shared directory would leave whichever process wrote
second unable to read the other's file; separate subdirectories cost
one extra token fetch and remove the failure mode.
On a host with no docker group (rootless docker, or a socket guarded
another way) the files stay owner-only — the safe direction to fail.
Build-mode then falls back to docker's default credentials and prints
a warning saying so; a public base image is unaffected.
On first boot after upgrading, an existing $HOME/.docker/config.json
is copied over once so credentials from a pre-upgrade docker login
are not lost. It is a one-time seed, never a sync — once the file
exists, registry manifests own it.
No static credential: IAM roles and credential helpers
registry carries a token, which means something has to keep that
token fresh. On a host whose identity is already granted — an EC2
instance with an ECR policy on its instance role, for example — that
is work you should not have to do. Use a docker credential helper
instead and skip registry entirely.
The helper is a binary docker execs on every pull. It reads the standard AWS credential chain, which includes the EC2 instance role via IMDS, so there is no token to rotate and no secret in your manifests.
# Debian / Ubuntu
sudo apt install amazon-ecr-credential-helper
# Amazon Linux 2023
sudo dnf install -y amazon-ecr-credential-helperThen declare it with helper instead of username/token:
registry "ecr" {
url = "123456789012.dkr.ecr.sa-east-1.amazonaws.com"
helper = "ecr-login"
}That becomes a credHelpers entry in $VOODU_ROOT/docker/config.json.
helper and username/token are mutually exclusive — docker's
credHelpers entry supersedes the matching auths entry, so accepting
both would silently ignore half of what you wrote. The parser rejects
the combination.
The value is docker's binary suffix, not a friendly name: docker execs
docker-credential-<helper>, so it is ecr-login, not ecr. Writing
ecr gets you an error naming the fix rather than a rewrite behind
your back.
Helper-mode registries mix freely with token-mode ones — pull ECR off
the instance role and ghcr.io with a bot token in the same file.
Which registries need a helper
Helpers exist to bridge an ambient host identity to a short-lived registry token. That is a narrow set:
| Registry | Mechanism | Why |
|---|---|---|
| ECR | helper = "ecr-login" | EC2/ECS identity via IMDS; tokens expire every 12h |
| GCR / Artifact Registry | helper = "gcr" or "gcloud" | GCE/workload identity |
| ACR | helper = "acr-env" | Azure managed identity |
| GHCR | username + token | A PAT is long-lived, and there is no ambient identity on a generic host. GitHub ships no official helper. |
| Docker Hub | username + token | Same — a PAT, long-lived. |
| DigitalOcean (DOCR) | username + token | The DO API token is the credential and does not expire unless you opt in with doctl registry login --expiry-seconds. No helper exists. |
For the bottom three there is nothing to rotate on a 12-hour clock, so a token is the right answer — put it in a config bucket (above) rather than in every developer's shell.
helper accepts any value: docker execs docker-credential-<helper>,
so a community or in-house helper works with no change to voodu. The
only names the parser refuses are ones with no such binary — today
ecr and aws, both of which mean ecr-login.
One file, many servers
The registry hostname usually varies per environment, which would
otherwise force one manifest per server. It does not: ${VAR}
interpolation runs against a file-global context built from every
env_from in the file, so a registry block picks up a bucket a
sibling resource declared even though registry takes no env_from
of its own.
deployment "fsw" "freeswitch" {
env_from = ["fsw/freeswitch"]
image = "${FS_ECR_URL}/freeswitch:bookworm"
}
registry "ecr" {
url = "${FS_ECR_URL}"
helper = "ecr-login"
}vd config fsw/freeswitch set FS_ECR_URL=... per server, then ship the
same file everywhere. The deployment's image and the registry's URL
resolve from one value, so they cannot drift apart.
The controller exports AWS_ECR_CACHE_DIR to a directory under
$VOODU_ROOT at boot. That matters: the helper caches issued tokens
under ${HOME}/.ecr by default, which ProtectHome=yes makes
unwritable, and the resulting failure reads like an AWS problem rather
than a sandbox one.
Check the role reaches ECR before blaming voodu:
sudo docker-credential-ecr-login get \
<<< "123456789012.dkr.ecr.sa-east-1.amazonaws.com"A JSON object with Username: AWS means the instance role works. An
error there is an IAM or IMDS problem — the required actions are
ecr:GetAuthorizationToken, ecr:BatchGetImage and
ecr:GetDownloadUrlForLayer.
Trade-offs
One credential per registry host. Voodu rewrites $VOODU_ROOT/docker/config.json atomically per apply — there's exactly one entry per registry hostname. Two operators each pushing their personal PAT will trample each other.
Use a bot / service-account token. Distribute via:
- A gitignored
.envrc+ direnv, or - A shared secret manager piping into shell env at apply time.
Personal PATs in voodu apply flow are antipattern.
No env_from on the block itself — but a bucket still reaches it. registry takes no env_from attribute, yet ${VAR} interpolation runs against a file-global context assembled from every env_from in the file before parsing, so a bucket a sibling resource declares feeds the registry block too. There is no bootstrap cycle: the CLI fetches the bucket over the controller API at apply time, long before any container starts. Use it — the credential becomes one value on the controller instead of a copy in every developer's .envrc, and rotating it is vd config set plus a re-apply.
Host-wide, not per-resource. A registry "ghcr" {} block authenticates every docker pull on that host. There's no per-deployment override — voodu trusts docker's credential resolution.
Atomic rewrites — voodu owns auths, preserves everything else. Voodu writes the new config.json to a tempfile, then renames. The auths section is owned entirely by voodu (declared registries overwrite, undeclared ones are removed). Unknown top-level keys (credsStore, HTTPHeaders, plugins, etc.) are preserved verbatim, so coexistence with docker login for non-voodu registries still works for the keys voodu doesn't touch.
Plain image pulls work without registry {}. Public images (ghcr.io/some/public:tag, docker.io/library/nginx:1.25) don't need a credential block. Only declare registry when an image needs auth.
ECR / token-rotating registries. Voodu doesn't refresh tokens on its own — you need a sidecar cronjob (above), an external runner, or a Docker credential helper. The registry block reads whatever value is in shell env at apply time.
See also
-
Private registry → ECR with an EC2 instance role — the complete flow end to end: helper install, dynamic hostname,
--force, and every failure mode with its diagnostic -
config & secrets— for${VAR}interpolation context