Private registry

Pull from GHCR, ECR, Harbor — credentials declared once, host-wide.

The registry block configures docker credentials so deployments can pull from private repositories. Voodu rewrites $VOODU_ROOT/docker/config.json on every apply — atomic, host-wide, no per-deployment dance. It is not ~/.docker/config.json: the controller runs sandboxed under systemd and cannot read a home directory. See where the credentials live.

Source: examples/registry/

Single registry (GHCR)

ghcr-private.hcl
registry "ghcr" {
  url      = "ghcr.io"
  username = "${GHCR_USER}"
  token    = "${GHCR_TOKEN}"
}

deployment "acme" "api" {
  image    = "ghcr.io/acme/private-api:1.0"
  replicas = 2
  ports    = ["3000"]
}

${GHCR_USER} / ${GHCR_TOKEN} resolve from the operator's shell env at parse time. Plaintext never lands in the manifest or in git.

The deployment carries nothing registry-specific — once any registry block on the host declares credentials for ghcr.io, every deployment that pulls from ghcr.io/* benefits transparently. Docker picks the right auth entry by hostname.

Multiple registries

multi-registry.hcl
registry "ghcr" {
  url      = "ghcr.io"
  username = "${GHCR_USER}"
  token    = "${GHCR_TOKEN}"
}

registry "harbor" {
  url      = "harbor.internal.acme.com"
  username = "${HARBOR_USER}"
  token    = "${HARBOR_TOKEN}"
}

deployment "public" "marketing-site" {
  image = "ghcr.io/acme/marketing-site:2.1"   # → ghcr block
  ports = ["8080"]
}

deployment "internal" "backend" {
  image = "harbor.internal.acme.com/team/backend:2.5"   # → harbor block
  ports = ["9000"]
}

Both registries coexist. config.json ends up with two entries under auths; docker matches the image hostname against the keys.

Notice the deployments live in different scopes (public, internal). registry is host-wide, not scoped — you declare it once, anywhere, and every deployment on the host can pull from any of the configured registries.

ECR with an EC2 instance role

ECR is the case where a registry block carrying a token is the wrong tool, for two unrelated reasons:

  1. Its tokens expire every 12 hours, so a token pair obliges someone — or some cronjob — to keep it fresh.
  2. Its hostname is per-account and per-region, so a literal URL means one manifest per environment.

Both go away. The host already has an identity, so helper replaces the credential entirely, and the hostname comes from a config bucket.

freeswitch.voodu
registry "ecr" {
  url    = "${FS_ECR_URL}"
  helper = "ecr-login"
}

statefulset "fsw" "freeswitch" {
  image    = "${FS_ECR_URL}/freeswitch:${FS_IMAGE_TAG:-bookworm}"
  replicas = 1
}

No token. No env_from. No hostname.

helper = "ecr-login" writes a credHelpers entry instead of an auths one, so docker execs docker-credential-ecr-login on every pull and the helper reads the instance role off IMDS. Nothing expires because nothing was stored. The value is docker's binary suffix, not a friendly name — ecr-login, not ecr.

${FS_ECR_URL} resolves with no env_from anywhere. The two-label statefulset causes its own fsw/freeswitch bucket to be fetched, and that interpolation context is file-global, so the registry block resolves against a bucket named after a different resource. One file, N servers.

Host setup

sudo apt install amazon-ecr-credential-helper       # Debian 13: in main, 0.7.1
sudo dnf install -y amazon-ecr-credential-helper    # Amazon Linux 2023

The instance role needs ecr:GetAuthorizationToken, ecr:BatchGetImage and ecr:GetDownloadUrlForLayer.

Prove the role reaches ECR before involving voodu. This one command separates an IAM problem from a platform problem:

sudo docker-credential-ecr-login get <<< "123456789012.dkr.ecr.sa-east-1.amazonaws.com"

{"Username":"AWS",...} means the role works. An error here is IAM or IMDS, and no voodu configuration will fix it.

Per-server values, then apply

vd config fsw/freeswitch set \
  FS_ECR_URL=123456789012.dkr.ecr.sa-east-1.amazonaws.com \
  FS_IMAGE_TAG=bookworm

vd apply -f freeswitch.voodu -r prod

Run the config set once per server with that server's account and region. The manifest never changes.

Updating a mutable tag

CI overwrites …/freeswitch:bookworm with new bytes. The HCL is byte-identical, so the diff is empty and a plain apply reports:

No changes. Nothing to apply.

Docker only fetches a tag it does not already have locally, so the host keeps serving the digest it cached on first deploy. Nothing is broken — there is genuinely no declared change. Pass --force:

vd apply -f freeswitch.voodu --force -r prod
✓ pulling images (--force) (12s)
✓ pulled 123456789012.dkr.ecr.sa-east-1.amazonaws.com/freeswitch:bookworm
✓ statefulset/fsw/freeswitch applied

--force bypasses the "No changes" short-circuit and makes the controller docker pull every image the manifests name before reconciling. If the pull moves the tag, the handler sees the running container on a different image ID than the tag now resolves to and rolls the replicas. If the tag was already current you get already up to date and nothing restarts — the correct outcome, not a swallowed apply.

A recreate is a real restart. For a media server with live calls or a database, pick the window.

When it fails

SymptomCause
No changes. Nothing to apply.Missing --force. The tag moved in the registry, not in the manifest.
already up to date, no restartThe tag did not move. The push went elsewhere, or to another tag.
pull access denied … no basic auth credentialsThe helper is not resolving — see the log check below.
registry block needs exactly one labelWrite registry "ecr" {, not registry {.
unknown shorthand flag: 'f' in -f during a buildNot about -f. Docker could not read $VOODU_ROOT/docker/config.json, which takes its CLI-plugin discovery down with it — and where build comes from the buildx plugin, the subcommand stops existing.
# Why the helper is not resolving
journalctl -u voodu-controller -n 30 | grep -iE "docker config|docker-credential|registry"

# Permissions, for the build-side failure
ls -la /opt/voodu/docker/     # want: drwxr-x--- root:docker, -rw-r----- root:docker
id | grep -o docker           # the SSH user must be in the docker group

docker-credential-ecr-login not found on PATH means the package is not installed. Restarting the controller repairs owner and mode on the config directory.

Service account tokens — not personal PATs

$VOODU_ROOT/docker/config.json is singular per registry, per host. Every apply that includes a registry block rewrites the file with whatever token is in the operator's shell at the time.

If two devs each apply with their personal GHCR PAT, the last applier wins — and the other dev's deploys break when the controller next pulls. The right shape for a team:

  1. Create a dedicated machine user / bot on the registry. GitHub: Settings → Developer settings → Personal access tokens (classic) on a service account user. Scope: read:packages.

  2. Store the token in your team password manager.

  3. Distribute via gitignored .envrc (direnv) inside the repo:

    # .envrc (gitignored)
    export GHCR_USER=acme-deploy-bot
    export GHCR_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  4. Every voodu apply substitutes the same value, regardless of who runs it. Rotation = one password-manager update + every dev's next direnv reload.

Config buckets instead of per-dev .envrc

registry takes no env_from attribute of its own — but a bucket still reaches it. ${VAR} interpolation runs against a file-global context assembled from every env_from in the file (and from each two-label resource's own scope/name bucket) before parsing, so a bucket a sibling resource declares feeds the registry block's url, username and token alike.

deployment "acme" "api" {
  env_from = ["acme/shared"]
  image    = "ghcr.io/acme/private-api:${TAG}"
}

registry "ghcr" {
  url      = "ghcr.io"
  username = "${GHCR_USER}"
  token    = "${GHCR_TOKEN}"      # from acme/shared
}
vd config acme/shared set GHCR_USER=acme-deploy-bot GHCR_TOKEN=ghp_xxx

There is no bootstrap cycle: the CLI fetches the bucket over the controller API at apply time, long before any container starts. 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 — no direnv reload on every machine.

Token alias

token and password are interchangeable — both decode into the same wire field:

registry "harbor" {
  url      = "harbor.internal.acme.com"
  username = "${HARBOR_USER}"
  password = "${HARBOR_TOKEN}"   # same as `token = "..."`
}

Use whichever reads better against your registry's UI conventions.

Apply

# Load shell env (direnv allow, or source manually)
direnv allow

# Apply
voodu apply -f ghcr-private.hcl

After apply, docker pull ghcr.io/acme/private-api:1.0 succeeds on the host without further docker login. The credentials persist across controller reboots and autoscale-driven pulls until the next voodu apply rewrites them.

On this page