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)
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
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:
- Its tokens expire every 12 hours, so a token pair obliges someone — or some cronjob — to keep it fresh.
- 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.
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 2023The 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 prodRun 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
| Symptom | Cause |
|---|---|
No changes. Nothing to apply. | Missing --force. The tag moved in the registry, not in the manifest. |
already up to date, no restart | The tag did not move. The push went elsewhere, or to another tag. |
pull access denied … no basic auth credentials | The helper is not resolving — see the log check below. |
registry block needs exactly one label | Write registry "ecr" {, not registry {. |
unknown shorthand flag: 'f' in -f during a build | Not 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 groupdocker-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:
-
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. -
Store the token in your team password manager.
-
Distribute via gitignored
.envrc(direnv) inside the repo:# .envrc (gitignored) export GHCR_USER=acme-deploy-bot export GHCR_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -
Every
voodu applysubstitutes the same value, regardless of who runs it. Rotation = one password-manager update + every dev's nextdirenv 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_xxxThere 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.hclAfter 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.
Related
registrymanifest reference — full field list + atomic-rewrite semantics