Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Self-Host a Docker Registry

By Kokil Thapa | Last reviewed: September 2026

You need a place to store private Docker images without paying per-seat fees or exposing build artefacts on a public hub. When you self-host a Docker registry, you control access, retention, and network path for every image your CI pipeline produces. That matters on real client projects where Laravel apps, worker images, and staging builds must stay inside your VPC or a single Ubuntu box you already operate. This guide walks through a production-ready setup: Registry 2.x behind a reverse proxy, TLS, basic auth, storage layout, and the push/pull workflow your GitLab CI or Deployer pipeline expects. If you are new to containers, start with our guide on installing Docker on Ubuntu before continuing.

Why should you self-host a Docker registry instead of using Docker Hub?

Public registries solve discovery. Private registries solve control. Docker Hub rate-limits anonymous pulls and charges for private repositories. AWS ECR and GitLab Container Registry are fine when you already live inside those platforms. A self-hosted registry makes sense when you run GitLab CI, self-hosted runners, or a small EC2 fleet and want one private store without another SaaS bill.

On sister sites I maintain with Deployer 7 and GitLab CI, images rarely leave the server subnet. Storing them locally cuts pull latency and removes an external dependency during deploys. For a deeper comparison of hosted options, read our container registry guide: Docker Hub vs GitLab vs ECR.

Self-Hosted Docker Registry TopologyDevelopersdocker push/pullCI Runnersbuild and pushNginx / TraefikTLS + basic authRegistry 2.xdistributionDisk Volume/var/lib/registryAll traffic over HTTPS on port 443
Self-host a Docker registry behind a reverse proxy with persistent local storage for private images.

The trade-off is operational ownership. You patch the host, renew TLS certs, monitor disk use, and plan backups. For teams already running Linux system administration on Ubuntu 22 or 24, that is usually acceptable. A single registry on a 2 vCPU / 4 GB RAM VM handles modest CI volume comfortably.

OptionBest forCost patternOps burden
Docker Hub privateSolo devs, public imagesPer-seat / pull limitsLow
GitLab Container RegistryGitLab-native CIIncluded with GitLabLow–medium
AWS ECRECS/EKS workloadsStorage + data transferLow
Self-hosted Registry 2Own VPS, air-gapped, multi-tool CIServer disk only (~Rs 2,000/mo, ~USD 15)Medium

Choose self-hosting when you want one registry that serves Docker Compose on a staging box, GitLab CI on the same subnet, and occasional laptop pulls without crossing the public internet for every layer.

How do you install and configure a self-hosted Docker registry on Ubuntu?

The official image is registry:2, built from the Distribution project. It speaks the Docker Registry HTTP API V2 and stores layers on local disk by default. The steps below assume Docker Engine is already installed and your user is in the docker group.

Create directories and a compose file

Keep registry data outside the container filesystem. I use a dedicated path under /srv on production hosts.

sudo mkdir -p /srv/registry/data /srv/registry/auth
sudo chown -R root:root /srv/registry

cat > /srv/registry/docker-compose.yml <<'EOF'
services:
  registry:
    image: registry:2
    restart: unless-stopped
    ports:
      - "127.0.0.1:5000:5000"
    environment:
      REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
      REGISTRY_HTTP_ADDR: 0.0.0.0:5000
    volumes:
      - /srv/registry/data:/var/lib/registry
EOF

Binding port 5000 to localhost prevents accidental public exposure before TLS is in place. Start the service:

cd /srv/registry
docker compose up -d
curl -s http://127.0.0.1:5000/v2/_catalog

An empty catalog returns {"repositories":[]}. That confirms the daemon is listening. For local-only testing you can add "insecure-registries": ["127.0.0.1:5000"] to /etc/docker/daemon.json, but production should never rely on that flag.

Resource limits and volume planning

Registry disk use grows with unique layers, not tag count alone. Tag the same digest ten times and storage barely moves. Push ten different builds and layers accumulate. Plan 50 GB minimum for active CI; monitor with df -h /srv/registry/data. Apply container limits as described in our post on limiting Docker container resources if the registry shares a busy host.

Docker Push Flow to Private Registrydocker builddocker tagdocker pushHTTPS POST blobsRegistry APIManifest PUTLayer Storecontent-addressedDuplicate layers deduplicate by SHA256 digestSame base image shared across many tags
When you self-host a Docker registry, pushes upload blobs first, then register the manifest that ties layers to a tag.

How do you secure a self-hosted Docker registry with TLS and authentication?

An open registry on the internet is a malware upload target within hours. Treat TLS and authentication as mandatory, not optional hardening. Docker clients refuse plain HTTP against remote hosts unless you weaken daemon settings globally.

Generate htpasswd credentials

Registry 2 supports htpasswd, token, and external auth. htpasswd is the fastest path for small teams. Create a user with a strong password — our password generator works fine for initial secrets.

docker run --rm --entrypoint htpasswd httpd:2 -Bbn ciuser 'YOUR_STRONG_PASSWORD' \
  > /srv/registry/auth/htpasswd

chmod 600 /srv/registry/auth/htpasswd

Update compose to mount auth and enable basic auth:

    volumes:
      - /srv/registry/data:/var/lib/registry
      - /srv/registry/auth:/auth
    environment:
      REGISTRY_AUTH: htpasswd
      REGISTRY_AUTH_HTPASSWD_REALM: Registry Realm
      REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd

Terminate TLS at Nginx

I terminate TLS on Nginx and proxy to localhost:5000. Certbot with Let's Encrypt keeps certs current. Full Nginx TLS patterns appear in our Traefik and reverse proxy guide; Nginx follows the same upstream idea.

server {
    listen 443 ssl http2;
    server_name registry.example.com;

    ssl_certificate     /etc/letsencrypt/live/registry.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/registry.example.com/privkey.pem;

    client_max_body_size 0;

    location / {
        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 900;
    }
}

Set client_max_body_size 0 so large image layers are not truncated. Reload Nginx after Certbot obtains the certificate. Official guidance on registry configuration lives in the Docker registry deploying docs.

Registry Security LayersUFW: allow 443, deny 5000 publiclyTLS 1.2+ via Let's Encrypthtpasswd or token auth on every requestSeparate CI push vs deploy pull usersDefence in depth for private image storage
Layer firewall rules, TLS, and credentials when you self-host a Docker registry on a public VPS.

Enable UFW on Ubuntu: allow OpenSSH and 443, deny direct 5000 from the world. Store CI credentials in GitLab masked variables, not in the repository. Rotate passwords when staff leave. For stricter setups, place the registry on a private network and reach it through VPN or SSH tunnel only.

How do you push and pull images from your private Docker registry?

Once TLS and auth work, the client workflow matches Docker Hub. Only the hostname changes. Follow a consistent tagging scheme — our Docker image tagging strategies post covers semver, git SHA, and environment tags.

  1. Build the image locally or in CI.
  2. Tag it with the registry hostname as prefix.
  3. Log in once per host or CI job.
  4. Push; verify with the catalog API.
  5. Pull on deploy targets using the same tag.
docker build -t myapp:1.4.2 .
docker tag myapp:1.4.2 registry.example.com/myapp:1.4.2
docker login registry.example.com
docker push registry.example.com/myapp:1.4.2

curl -u ciuser:YOUR_STRONG_PASSWORD \
  https://registry.example.com/v2/_catalog

docker pull registry.example.com/myapp:1.4.2

GitLab CI example

A typical pipeline builds on a runner, pushes to your registry, then SSH-deploys or triggers Compose on staging. Store CI_REGISTRY_USER and CI_REGISTRY_PASSWORD as protected variables.

build:
  stage: build
  script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" registry.example.com
    - docker build -t registry.example.com/myapp:$CI_COMMIT_SHA .
    - docker push registry.example.com/myapp:$CI_COMMIT_SHA

On production Laravel stacks I often build assets in CI and push a single runtime image. The server pulls by immutable SHA tag, not latest. That mirrors the GitLab CI → Deployer flow on projects like Notary Kathmandu and related sister sites. Read self-hosted CI runners setup and security for runner hardening that pairs with a private registry.

For multi-architecture builds targeting both amd64 and arm64 runners, use Buildx as covered in Docker Buildx multi-platform builds. The registry stores each architecture manifest under the same tag when you push a manifest list.

How do you back up, garbage-collect, and maintain a production Docker registry?

Registry data is plain files under /srv/registry/data. Back up that directory nightly with rsync or restic to another disk or S3-compatible storage. If you already run MinIO for self-hosted S3, you can configure Registry to use S3 backend instead of filesystem — useful when the registry must survive disk loss on the app server.

Filesystem backup script

#!/bin/bash
set -euo pipefail
SRC="/srv/registry/data"
DEST="/backup/registry/$(date +%F)"
mkdir -p "$DEST"
rsync -a --delete "$SRC/" "$DEST/"

Schedule it with cron on the host. Test restores quarterly by spinning a throwaway registry container pointed at a restored copy.

Garbage collection

Deleting a tag does not reclaim disk immediately. Unreferenced layers remain until garbage collection runs. Schedule downtime or read-only mode, then execute:

docker exec registry registry garbage-collect /etc/docker/registry/config.yml --delete-untagged

For compose setups without the config file inside the container, run the GC tool in a one-off container mounting the same data volume. Always back up before GC. Wrong flags can remove layers still referenced by an unexpected manifest.

Registry Maintenance CycleMonitor diskNightly backupRenew TLSRun GCPrune old tagsPatch host OS
Ongoing ops when you self-host a Docker registry: monitor storage, automate backups, and schedule garbage collection.

Define retention in CI: keep last ten SHA tags per app, delete older tags with a scheduled script hitting the registry API. Document the runbook for your team. If you prefer managed oversight, support and maintenance covers the same host patching and backup discipline.

Common production failures I have seen: disk full mid-push (corrupt partial uploads), expired Let's Encrypt cert (sudden CI failures), and opcache-style stale nginx upstream after registry restart. Watch /var/log/nginx/error.log and registry container logs with docker compose logs -f registry.

Key Takeaways

  • Bind Registry 2 to localhost and expose it only through Nginx or Traefik with valid TLS certificates.
  • Enable htpasswd or token auth before any image reaches the registry — an open registry is an immediate security incident.
  • Tag images with registry.example.com/app:git-sha and avoid mutable latest tags in production deploys.
  • Back up /srv/registry/data nightly and test restores; registry data is your deploy artefact store.
  • Run garbage collection on a schedule after backups to reclaim disk from deleted tags.
  • Integrate login, build, and push steps into GitLab CI or your self-hosted runner pipeline for repeatable releases.

People Also Ask

Can Docker Registry run without Docker Hub at all?

Yes. Once images live in your private registry, builds and deploys never contact Docker Hub except when your Dockerfile starts with FROM ubuntu:24.04 or another public base. Mirror those base images into your registry or use a pull-through cache if outbound rate limits bite.

How much disk space does a private Docker registry need?

Plan from your largest app image times retained build count. A 400 MB Laravel runtime pushed daily with thirty-day retention needs roughly 12 GB plus layer deduplication savings. Monitor weekly; expand the volume before you hit ninety percent.

Is the official registry enough or do I need Harbor?

Registry 2 covers push, pull, and basic auth for small teams. Harbor adds a UI, RBAC, vulnerability scanning, and replication. Start with Registry 2 on one Ubuntu server; move to Harbor when audit requirements or scan policies demand it.

Does a self-hosted registry work with Kubernetes?

Yes. Configure an imagePullSecret with the same credentials you use for docker login. Point pod specs at registry.example.com/namespace/app:tag. Ensure worker nodes trust your TLS certificate.

Ship private images on infrastructure you control

When you self-host a Docker registry, you own the full path from CI build to production pull. Start with Registry 2 on Ubuntu, lock it behind TLS and htpasswd, wire GitLab CI to push immutable tags, and automate backups before the first production deploy depends on it. The setup takes an afternoon; the operational habits pay off on every release after that.

Need help wiring a private registry into a Laravel CI pipeline or a multi-site Deployer workflow? Review our Adventure Third Pole Trek deployment stack or explore related guides on Docker networking and volumes, Docker Compose for local Laravel, and hosting Laravel on AWS EC2. For hands-on implementation on your server, contact us — or browse enterprise application development services if you are planning a full platform rollout.

Frequently Asked Questions

A private store for Docker images you run on your own server, using Registry 2 behind TLS and auth so CI and deploy targets pull without public hubs.

Public registries solve discovery; private registries solve control. Docker Hub rate-limits anonymous pulls and charges for private repositories. A self-hosted Registry 2 on your Ubuntu VPS gives one private store without another SaaS bill when you already run GitLab CI or self-hosted runners. Images stay on your subnet, which cuts pull latency and removes an external dependency during deploys. The trade-off is operational ownership: you patch the host, renew TLS certs, monitor disk use, and plan backups. For teams already administering Ubuntu 22 or 24 servers, that is usually acceptable.

Mainly server disk: roughly Rs 2,000/month (~USD 15) on a modest VM. No per-seat fees, unlike Docker Hub private repos.

Assuming Docker Engine is installed, create /srv/registry/data and /srv/registry/auth, then run the official registry:2 image via Docker Compose. Bind port 5000 to 127.0.0.1:5000 so the daemon is not publicly exposed before TLS is ready. Mount /srv/registry/data to /var/lib/registry for persistent storage and set REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY accordingly. Start with docker compose up -d and confirm with curl http://127.0.0.1:5000/v2/_catalog returning an empty repositories list. Never rely on insecure-registries in production; terminate TLS at Nginx or Traefik instead.

Treat TLS and authentication as mandatory. Generate htpasswd credentials with the httpd:2 image, mount them at /srv/registry/auth, and set REGISTRY_AUTH to htpasswd in Compose. Terminate TLS on Nginx with Let's Encrypt via Certbot, proxying to localhost:5000. Set client_max_body_size 0 so large layers are not truncated. Enable UFW to allow OpenSSH and 443 while denying direct port 5000 from the internet. Store CI credentials in GitLab masked variables, rotate passwords when staff leave, and consider VPN-only access for stricter setups.

Build locally or in CI, tag with the registry hostname as prefix, log in once, then push and verify via the catalog API. Example flow: docker build, docker tag myapp:1.4.2 registry.example.com/myapp:1.4.2, docker login registry.example.com, docker push, then curl -u user:pass https://registry.example.com/v2/_catalog. Deploy targets pull with docker pull using the same tag. Follow a consistent tagging scheme using semver or git SHA rather than mutable latest tags in production.

Plan 50 GB minimum for active CI. A 400 MB image pushed daily with 30-day retention needs roughly 12 GB before layer deduplication savings.

Yes. Once your images live in the private registry, builds and deploys never contact Docker Hub except when a Dockerfile starts with a public base image like ubuntu:24.04. Mirror those base images into your registry or use a pull-through cache if outbound rate limits become a problem. On real client projects where Laravel apps, worker images, and staging builds must stay inside your VPC, keeping everything local removes an external dependency during deploys.

Registry 2 covers push, pull, and basic auth for small teams running GitLab CI or Docker Compose on a staging box. Harbor adds a UI, RBAC, vulnerability scanning, and replication. Start with Registry 2 on one Ubuntu server behind Nginx with htpasswd; move to Harbor when audit requirements or scan policies demand it. A single registry on a 2 vCPU / 4 GB RAM VM handles modest CI volume comfortably without the extra operational surface Harbor introduces.

Yes. Configure an imagePullSecret with the same credentials you use for docker login. Point pod specs at registry.example.com/namespace/app:tag and ensure worker nodes trust your TLS certificate. The registry stores each architecture manifest under the same tag when you push a manifest list via Buildx for amd64 and arm64 targets. Private images ship on infrastructure you control without crossing the public internet for every layer pull.

Store CI_REGISTRY_USER and CI_REGISTRY_PASSWORD as protected GitLab variables, then add a build stage that logs in, builds, and pushes. A typical script runs docker login against registry.example.com, docker build -t registry.example.com/myapp:$CI_COMMIT_SHA ., and docker push. The server then pulls by immutable SHA tag during deploy, mirroring the GitLab CI to Deployer flow used on production Laravel stacks. Pair this with self-hosted runner hardening so credentials and image paths stay on your subnet.

Registry data is plain files under /srv/registry/data. Back up that directory nightly with rsync or restic to another disk or S3-compatible storage. A simple cron script can rsync with --delete to a dated folder under /backup/registry. Test restores quarterly by spinning a throwaway registry container pointed at a restored copy. If you run MinIO for self-hosted S3, you can configure Registry to use an S3 backend instead of filesystem so the registry survives disk loss on the app server.

Deleting a tag does not reclaim disk immediately because unreferenced layers remain until garbage collection runs. Schedule downtime or read-only mode, back up first, then run docker exec registry registry garbage-collect /etc/docker/registry/config.yml --delete-untagged. For Compose setups without the config inside the container, run the GC tool in a one-off container mounting the same data volume. Wrong flags can remove layers still referenced by an unexpected manifest, so always back up before GC and define retention in CI.

I have repeatedly seen three issues in production: disk full mid-push causing corrupt partial uploads, expired Let's Encrypt certificates causing sudden CI failures, and stale Nginx upstream cache after a registry restart. Monitor storage with df -h /srv/registry/data, automate cert renewal with Certbot, and watch /var/log/nginx/error.log plus docker compose logs -f registry. Plan garbage collection after backups to reclaim space from deleted tags, and expand the volume before disk use hits ninety percent.

AWS ECR fits ECS or EKS workloads with storage plus data transfer billing. GitLab Container Registry is low-friction when CI already lives inside GitLab. Choose self-hosted Registry 2 when you want one registry serving Docker Compose on staging, GitLab CI on the same subnet, and occasional laptop pulls without per-seat fees or crossing the public internet for every layer. It suits air-gapped setups and small EC2 fleets where ops burden is acceptable because you already administer Ubuntu hosts.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: