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-pull credentials. 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.nix pointing 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, everywhere registry.nix:5000 appears below (including the REGISTRY variable 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.