How to Set Up a Container Registry with CI
Gisia does not ship a built-in container registry. That is fine — the official Docker Registry works great next to Gisia. In this tutorial you will run a registry at registry.nix:5000 (plain HTTP on your local network, no TLS), wire its credentials into Gisia as CI/CD variables, and write a pipeline that pushes images from protected branches and pulls them from any branch.
The credential scheme uses two registry accounts exposed through different variable names:
| Variable | Account | Protected | Available on |
|---|---|---|---|
REGISTRY_USER / REGISTRY_PASSWORD |
ci-push (can push) |
Yes | Protected branches and tags only |
REGISTRY_RO_USER / REGISTRY_RO_PASSWORD |
ci-pull (pull only) |
No | Every pipeline |
Because the push credentials are protected variables, a pipeline for a random feature branch never even sees them — only protected branches such as main can publish images.
Step 1 — Run the Docker Registry
On the machine that will host the registry, create an auth file with the two accounts in a working directory of your choice:
mkdir -p auth
docker run --rm --entrypoint htpasswd httpd:2 -Bbn ci-push 'CHANGE_ME_PUSH' > auth/htpasswd
docker run --rm --entrypoint htpasswd httpd:2 -Bbn ci-pull 'CHANGE_ME_PULL' >> auth/htpasswd
Then start the registry on port 5000 from the same directory:
docker run -d --restart=always --name registry \
-p 5000:5000 \
-v "$(pwd)/auth":/auth \
-v registry-data:/var/lib/registry \
-e REGISTRY_AUTH=htpasswd \
-e REGISTRY_AUTH_HTPASSWD_REALM="Gisia Registry" \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
registry:2
Plain htpasswd auth cannot distinguish push from pull — both accounts technically have full access at the registry level. The protected/non-protected split is enforced by Gisia: unprotected pipelines only receive the
ci-pullcredentials. If you need hard enforcement, put a token-auth proxy in front of the registry later.
Step 2 — Make registry.nix Resolvable, Without TLS
Do not use /etc/hosts for this: CI jobs running in containers do not inherit the host’s hosts file, so the name would resolve on the host but break inside jobs. Pick one of these instead:
- Local DNS — add an A record for
registry.nixpointing to the registry host in your local DNS server (router, dnsmasq, Pi-hole, …). Everything on the network, including job containers, resolves it the same way. - Plain IP — skip the name entirely and use the registry host’s address, e.g.
192.168.1.50:5000, everywhereregistry.nix:5000appears below (including theREGISTRYvariable in the next step).
Then allow plain HTTP. Docker refuses non-TLS registries by default, so add the address to /etc/docker/daemon.json on every Docker host that will push or pull:
{
"insecure-registries": ["registry.nix:5000"]
}
Restart Docker afterwards:
sudo systemctl restart docker
Also make sure the network itself allows the traffic: every machine that talks to the registry — the runner host, the CI job containers, your workstation — must be able to reach the registry host on port 5000. Check firewalls and any VLAN isolation, and verify from each machine:
curl -u ci-pull:CHANGE_ME_PULL http://registry.nix:5000/v2/_catalog
Step 3 — Add the CI/CD Variables in Gisia
In your project go to Settings → CI/CD → Variables and add:
| Key | Value | Protect variable | Masked |
|---|---|---|---|
REGISTRY |
registry.nix:5000 |
No | No |
REGISTRY_USER |
ci-push |
Yes | No |
REGISTRY_PASSWORD |
the push password | Yes | Yes |
REGISTRY_RO_USER |
ci-pull |
No | No |
REGISTRY_RO_PASSWORD |
the pull password | No | Yes |
Check Protect variable for the push credentials — that is what limits them to pipelines running on protected branches and tags. Your default branch (main) is protected out of the box; manage the list under Settings → Repository → Protected branches.
Step 4 — Write the Pipeline
With the docker executor from How to run pipelines, each job runs inside a container — and the default job image has no docker CLI (docker: command not found). Two things fix that.
First, let job containers use the host’s Docker daemon by mounting the Docker socket in the runner config. Start the runner with its config directory bind-mounted, so config.toml lives on the host:
docker run -d --name gisia-docker-runner \
-v ./config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:v18.11.2
(If your runner is already running without the config mount, recreate it with the command above and register it again, or edit the file in place with docker exec -it gisia-docker-runner vi /etc/gitlab-runner/config.toml.)
Then edit ./config/config.toml and add the socket to the job volumes:
[[runners]]
[runners.docker]
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
The runner reloads the config automatically. Since jobs now talk to the host daemon through the socket, the insecure-registries entry from Step 2 on the runner host is the one that applies — no per-job configuration needed.
Second, run the jobs in an image that ships the Docker CLI: docker:28-cli.
The project also needs something to build. If it does not have a Dockerfile yet, commit a minimal one at the repository root:
FROM alpine:3.22
CMD ["echo", "Hello from the Gisia registry"]
Then add a .gitlab-ci.yml that pushes from main and pulls everywhere:
stages:
- build
- test
build-and-push:
stage: build
image: docker:28-cli
script:
- echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
- docker build -t "$REGISTRY/$CI_PROJECT_PATH:$CI_COMMIT_SHORT_SHA" -t "$REGISTRY/$CI_PROJECT_PATH:latest" .
- docker push "$REGISTRY/$CI_PROJECT_PATH:$CI_COMMIT_SHORT_SHA"
- docker push "$REGISTRY/$CI_PROJECT_PATH:latest"
rules:
- if: $CI_COMMIT_BRANCH == "main"
smoke-test:
stage: test
image: docker:28-cli
script:
- echo "$REGISTRY_RO_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_RO_USER" --password-stdin
- docker pull "$REGISTRY/$CI_PROJECT_PATH:latest"
- docker run --rm "$REGISTRY/$CI_PROJECT_PATH:latest"
On main, both jobs run: the image is built, tagged with the commit SHA and latest, and pushed. On a feature branch only smoke-test runs, and since the pipeline is unprotected, $REGISTRY_PASSWORD is simply not present — an accidental push attempt fails at docker login.
Step 5 — Verify
Push a commit to main, watch the pipeline succeed, then list what landed in the registry:
curl -u ci-pull:CHANGE_ME_PULL http://registry.nix:5000/v2/_catalog
You should see your project path listed. From now on every merge to main publishes a fresh image, and any branch can pull it for testing or deployment.
Security Considerations
This setup trades isolation for simplicity. Know what you are accepting:
The Docker socket is root on the runner host. Any CI job that can reach /var/run/docker.sock can start a privileged container, mount the host filesystem, and read anything on the machine — including secrets of other jobs on the same host. Docker-in-docker (privileged = true) is not safer: a privileged container can escape to the host just the same. The protected variables guard your registry credentials, not the host.
So the question is who can run pipelines on this runner:
- Trusted team or homelab — this setup is fine as is.
- Run the runner on a dedicated, disposable host (a small VM works), never on the Gisia server itself.
- Pin the runner to protected branches if you allow outside contributors, so feature-branch pipelines from strangers never reach the socket.
- Untrusted users on a shared instance — drop the socket entirely and build with a daemonless tool like buildah, which needs no daemon and can run without privileges (kaniko used to be the go-to here but was archived in 2025; Chainguard maintains a fork).
The registry itself is plain HTTP with basic auth. Credentials and image contents cross the network unencrypted, and htpasswd cannot enforce read-only accounts. That is acceptable on a private LAN; never expose port 5000 to the internet without putting TLS and real token auth in front.