DEV Community

FOLASAYO SAMUEL OLAYEMI
FOLASAYO SAMUEL OLAYEMI

Posted on

Ditch GHCR: A Production-Grade Self-Hosted Docker Registry on a Single DigitalOcean Droplet

If you have ever hit the wall on a hosted registry's free private tier a 500 MB cap here, a per-seat charge there and you already run your own servers, there is a question worth asking: why not host the registry yourself?

The registry software is not the scary part. registry:2 is the CNCF Distribution project, the same reference implementation that sits underneath most managed registries. Harbor wraps it. Several big managed registries are built on it. It is boring, battle-tested, and idles at well under 50 MB of RAM.

What separates a hobby setup from a production one is everything around the binary: durable storage, TLS, authentication on both push and pull, garbage collection, and not leaving a naked port open to the internet. This article walks through a setup that gets all of that right, end to end, on a single droplet, with copy-paste commands you can follow start to finish.

By the end you will have:

  • A private registry running behind HTTPS on your own subdomain
  • Image layers stored in S3-compatible object storage (durable, effectively unbounded) instead of on the droplet's disk
  • A GitHub Actions pipeline that audits dependencies, builds, scans for CVEs, pushes, and deploys
  • Automatic garbage collection so the storage does not grow forever

Everything below uses placeholder names swap in your own domain, bucket, and paths.

The architecture

The single most common self-hosted registry mistake is publishing port 5000 to 0.0.0.0. That binds to every interface including the public one, so the whole thing sits open on the internet. We avoid that entirely.

Because most people fronting their containers already run Nginx Proxy Manager (NPM), the clean approach is:

  • The registry container joins the same Docker network as NPM and is reachable only by its container name no public host port.
  • NPM handles TLS and proxying. It requests the Let's Encrypt certificate and terminates HTTPS.
  • The registry handles its own authentication via an htpasswd file. This is deliberate: if both NPM (via an Access List) and the registry tried to own the Authorization header, you would get duplicate-header conflicts. Letting the registry own auth keeps that clean.
  • Object storage is the backend. Local disk on one droplet is a single point of failure and fills up. Pointing the registry at an S3-compatible bucket gives durability and room to grow without touching the disk.
[GitHub Actions]  --push over HTTPS-->  [NPM : TLS]  -->  [registry container]  -->  [S3-compatible object storage]
                                                                  ^
[Droplet deploy]  --pull over HTTPS----------------------------- /
Enter fullscreen mode Exit fullscreen mode

The registry port is never exposed. NPM is the only thing that talks to it, and it does so over the internal Docker network.

Prerequisites

  • A droplet running Docker and Nginx Proxy Manager
  • A DNS A record registry.yourdomain.com → your droplet's public IP
  • An S3-compatible object storage bucket (DigitalOcean Spaces works well here since the registry ships with a native S3 driver) plus an access key / secret pair
  • Your cloud firewall allowing only ports 80, 443, and 22 and not 5000

Step 1: Find your NPM Docker network

Everything hangs off getting the network name right, so grab it first:

docker network ls | grep -iE 'proxy|npm'
Enter fullscreen mode Exit fullscreen mode

Note the name it prints. Wherever you see npm-network below, substitute yours.

Step 2: The registry compose file

Create /home/apps/registry/docker-compose.yml:

services:
  registry:
    image: registry:2
    container_name: registry
    restart: always
    networks:
      - npm-network
    ports:
      - "127.0.0.1:5000:5000"     # localhost only, for on-server curl tests; NPM reaches it via the network, not this
    environment:
      REGISTRY_HTTP_SECRET: "${REGISTRY_HTTP_SECRET}"
      REGISTRY_AUTH: htpasswd
      REGISTRY_AUTH_HTPASSWD_REALM: "Private Registry"
      REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
      REGISTRY_STORAGE_DELETE_ENABLED: "true"     # required for garbage collection to reclaim space
      REGISTRY_STORAGE: s3
      REGISTRY_STORAGE_S3_REGION: us-east-1        # dummy value; some S3-compatible providers ignore it but the driver requires it
      REGISTRY_STORAGE_S3_REGIONENDPOINT: https://nyc3.digitaloceanspaces.com
      REGISTRY_STORAGE_S3_BUCKET: your-registry-bucket
      REGISTRY_STORAGE_S3_ACCESSKEY: "${SPACES_KEY}"
      REGISTRY_STORAGE_S3_SECRETKEY: "${SPACES_SECRET}"
      REGISTRY_STORAGE_S3_SECURE: "true"
      REGISTRY_STORAGE_S3_FORCEPATHSTYLE: "true"   # safest with Spaces-style endpoints (avoids vhost/SNI cert issues)
    volumes:
      - ./auth:/auth:ro
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

networks:
  npm-network:
    external: true
Enter fullscreen mode Exit fullscreen mode

A few of these lines matter more than they look:

  • REGISTRY_STORAGE_DELETE_ENABLED: "true" without this, garbage collection cannot reclaim anything. The registry will happily grow forever.
  • REGISTRY_STORAGE_S3_FORCEPATHSTYLE: "true" path-style requests avoid the virtual-host / SNI certificate mismatches you can hit with some S3-compatible endpoints.
  • The bind is 127.0.0.1:5000:5000, not 5000:5000. That published port exists only so you can curl the registry locally for testing. NPM does not use it — it reaches the container over the Docker network by name.

Prefer to start on local disk? Delete every REGISTRY_STORAGE_S3_* line and the REGISTRY_STORAGE: s3 line, then add - ./data:/var/lib/registry under volumes. You can migrate to object storage later without changing anything else.

Step 3: Bring it up on the server

Paste this block. Edit the storage keys in the generated .env afterward.

mkdir -p /home/apps/registry/auth && cd /home/apps/registry

# create the .env (edit the storage keys after)
cat > .env <<EOF
REGISTRY_HTTP_SECRET=$(openssl rand -hex 32)
SPACES_KEY=your_access_key
SPACES_SECRET=your_secret_key
EOF

# basic-auth user — bcrypt (-B) is mandatory for the registry's htpasswd
sudo apt-get install -y apache2-utils
htpasswd -Bc /home/apps/registry/auth/htpasswd ci-pusher    # prompts for a password -> this becomes REGISTRY_PASSWORD

docker compose up -d
docker compose logs --tail=20 registry
Enter fullscreen mode Exit fullscreen mode

The password you set for ci-pusher is what GitHub Actions and your server will use to log in. Keep it handy.

Step 4: Configure Nginx Proxy Manager

In the NPM UI:

Hosts → Proxy Hosts → Add Proxy Host

  • Domain Names: registry.yourdomain.com
  • Scheme: http
  • Forward Hostname: registry (the container name)
  • Forward Port: 5000
  • Block Common Exploits: on
  • Websockets Support: off

SSL tab

  • SSL Certificate: Request a new SSL Certificate
  • Force SSL: on
  • HTTP/2 Support: on
  • Agree to the Let's Encrypt terms, then Save.

Advanced tab: paste exactly this, and nothing else:

client_max_body_size 0;
Enter fullscreen mode Exit fullscreen mode

That one directive is non-negotiable. NPM's default request body cap is around 1 MB, and image layers are far bigger, without lifting the cap, pushes fail with HTTP 413 Request Entity Too Large. Resist the urge to also paste proxy_set_header lines here; NPM already sets them, and adding duplicates is exactly what causes header conflicts.

Step 5: Smoke test

From the server:

curl -u ci-pusher:YOURPASS https://registry.yourdomain.com/v2/_catalog
Enter fullscreen mode Exit fullscreen mode

Then a full round trip from your laptop:

docker login registry.yourdomain.com -u ci-pusher
docker pull hello-world
docker tag hello-world registry.yourdomain.com/test:1
docker push registry.yourdomain.com/test:1
Enter fullscreen mode Exit fullscreen mode

If the push succeeds, TLS, auth, the body-size cap, and object storage are all working together. Double-check the cloud firewall still exposes only 80, 443, and 22.

Step 6: The GitHub Actions pipeline

This is a four-stage pipeline: audit dependencies, build and push, scan the pushed image for CVEs, then deploy over SSH. It assumes an upstream quality-check workflow triggers it, but you can change the trigger to a plain push on main if you prefer.

Add two repository secrets first (Settings → Secrets and variables → Actions):

  • REGISTRY_USERNAME = ci-pusher
  • REGISTRY_PASSWORD = the password you set for that user

You will also need the usual deploy secrets: SERVER_IP, SERVER_USER, and SERVER_SSH_KEY.

name: Build and Deploy
on:
  workflow_run:
    workflows: ["CodeQuality Checks"]
    types:
      - completed
    branches:
      - main
env:
  REGISTRY: registry.yourdomain.com
concurrency:
  group: deploy-web
  cancel-in-progress: false
jobs:
  dependency-check:
    name: Dependency Vulnerability Check
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout code
        uses: actions/checkout@v6
        with:
          ref: ${{ github.event.workflow_run.head_sha }}
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Audit dependencies
        run: npm audit --audit-level=high

  build-and-push:
    name: Build & Push Image
    needs: dependency-check
    runs-on: ubuntu-latest
    timeout-minutes: 20
    outputs:
      image: ${{ steps.image-name.outputs.image }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v6
        with:
          ref: ${{ github.event.workflow_run.head_sha }}
      - name: Set image name
        id: image-name
        run: echo "image=${{ env.REGISTRY }}/web-app" >> $GITHUB_OUTPUT
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4
      - name: Log in to registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}
      - name: Extract Docker image metadata
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ steps.image-name.outputs.image }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}
      - name: Build and push Docker image
        uses: docker/build-push-action@v7
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          # Public client-side build vars are fine to bake in here.
          # Never pass real server-side secrets as build-args — they persist in image layers.
          build-args: |
            NEXT_PUBLIC_SOME_KEY=${{ secrets.NEXT_PUBLIC_SOME_KEY }}
            NEXT_PUBLIC_SOME_ID=${{ secrets.NEXT_PUBLIC_SOME_ID }}

  image-scan:
    name: Container Security Scan
    needs: build-and-push
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # pin the action by SHA
        env:
          TRIVY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
          TRIVY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
        with:
          image-ref: ${{ needs.build-and-push.outputs.image }}:latest
          severity: CRITICAL,HIGH
          exit-code: '1'
          ignore-unfixed: true

  deploy:
    name: Deploy to Server
    runs-on: ubuntu-latest
    needs: [build-and-push, image-scan]
    timeout-minutes: 10
    steps:
      - name: Deploy to Server via SSH
        uses: appleboy/ssh-action@v1.2.5
        env:
          REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
        with:
          host: ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          port: 22
          envs: REGISTRY_PASSWORD
          script: |
            echo "$REGISTRY_PASSWORD" | docker login registry.yourdomain.com -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin
            cd /home/apps/web-app
            docker compose -f docker-compose.yml pull
            docker compose -f docker-compose.yml up -d --remove-orphans
            docker image prune -f
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out because they trip people up on the migration away from a hosted registry:

The Trivy scan needs credentials. Your image is private now, so the scanner has to authenticate to pull it. That is what the TRIVY_USERNAME / TRIVY_PASSWORD env vars are for. Forgetting them produces a confusing "manifest unknown" style failure.

No more packages: write permissions block. Those scopes existed to let the built-in token push to the hosted registry. Talking to your own registry uses your REGISTRY_USERNAME / REGISTRY_PASSWORD secrets instead, so those permission blocks are dead weight and can go.

Also note the deploy step relies on docker compose pull plus pull_policy: always rather than a standalone docker pull. Doing it in one place removes a whole class of "the compose file and the pipeline disagree about which tag to run" bugs.

Step 7: The application compose file

On the server, the app's docker-compose.yml only needs its image line pointed at the new registry:

services:
  web-app:
    image: registry.yourdomain.com/web-app:latest
    pull_policy: always
    container_name: web-app
    ports:
      - "${HOST_PORT:-3000}:3000"
    restart: unless-stopped
    env_file:
      - .env
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
Enter fullscreen mode Exit fullscreen mode

One nice optimization: add a hosts entry so the server resolves the registry domain to itself instead of hairpinning out to the public IP and back. TLS still validates because the SNI name matches the certificate.

echo "127.0.0.1 registry.yourdomain.com" | sudo tee -a /etc/hosts
Enter fullscreen mode Exit fullscreen mode

Step 8: Garbage collection

Nothing reclaims disk (or object storage) automatically. Deleting a tag only removes the reference; the underlying blobs linger until you sweep them. Schedule that sweep:

sudo tee /etc/cron.d/registry-gc >/dev/null <<'EOF'
30 3 * * 0 root docker exec registry bin/registry garbage-collect --delete-untagged /etc/docker/registry/config.yml
EOF
Enter fullscreen mode Exit fullscreen mode

That runs every Sunday at 03:30. One important caveat: garbage collection can delete a blob that an in-flight push is depending on, corrupting that push. Running it in a quiet window is the simple mitigation. If you ever push around the clock, put the registry into read-only mode for the duration of the sweep instead set REGISTRY_STORAGE_MAINTENANCE_READONLY_ENABLED=true, run GC, then flip it back.

Where this stops being enough

Bare Distribution deliberately does not do a few things that larger organizations eventually want: image signing and policy enforcement, team-level RBAC, cross-region replication, a web UI, and audit logs. If you need those, the move is not to abandon self-hosting it is to run Harbor, which is itself self-hosted and CNCF-graduated. It bundles its own registry, database, and vulnerability scanner, and realistically wants a couple of gigabytes of RAM and more moving parts.

For a single-organization setup with a handful of projects, though, registry:2 + object storage + auth + scheduled GC is a completely legitimate production answer. The registry engine is the same one the big players use; whether your deployment is production-grade comes down to the three things this guide made sure to cover durable storage, TLS with authentication, and garbage collection.

A couple of upgrades to consider

Pin deploys to the immutable SHA tag. latest is ambiguous and makes rollbacks guesswork. The pipeline above already produces a sha-<commit> tag on every build passing that SHA through to the compose image: line gives you deterministic, reversible deploys. When something breaks, you redeploy the previous SHA instead of hoping latest still points where you think it does.

Ship the registry's logs to wherever your other logs live. If you already run a log-aggregation stack, point a collector at the registry container so pushes, pulls, and GC runs show up alongside everything else. A registry is quiet right up until it is not, and having its logs where you already look saves you a bad afternoon.

Wrapping up

The takeaway is that "self-hosted registry" and "what serious infrastructure uses" are not opposite ends of a spectrum they share the same core. The gap is entirely operational, and it is a gap you can close in an afternoon: bind to localhost, let a reverse proxy own TLS, authenticate both directions, put the bytes in durable storage, and sweep the garbage on a schedule.

Do that, and you have a private registry that costs you nothing beyond the storage you actually use, lives next to the servers that pull from it, and answers to no one else's free-tier limits.

Top comments (0)