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_from field — 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

FieldTypeMeaning
urlstringBare host (no scheme). E.g. "ghcr.io", "registry.gitlab.com", "123456789012.dkr.ecr.us-east-1.amazonaws.com".
usernamestringRegistry account.
token or passwordstringToken / 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 required

Validation

  • 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. Two registry "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-1

Multiple 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 credentials

Declare 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/docker

Permissions 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-helper

Then 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:

RegistryMechanismWhy
ECRhelper = "ecr-login"EC2/ECS identity via IMDS; tokens expire every 12h
GCR / Artifact Registryhelper = "gcr" or "gcloud"GCE/workload identity
ACRhelper = "acr-env"Azure managed identity
GHCRusername + tokenA PAT is long-lived, and there is no ambient identity on a generic host. GitHub ships no official helper.
Docker Hubusername + tokenSame — a PAT, long-lived.
DigitalOcean (DOCR)username + tokenThe 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

On this page