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
htpasswdfile. This is deliberate: if both NPM (via an Access List) and the registry tried to own theAuthorizationheader, 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---/
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
Arecordregistry.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, and22- and not5000
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'
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
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, not5000:5000. That published port exists only so you cancurlthe 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
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
- Domain Names:
- 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;
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
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
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-pusherREGISTRY_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
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
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_PASSWORDenv vars are for. Forgetting them produces a confusing “manifest unknown” style failure. - No more
packages: writepermissions block. Those scopes existed to let the built-in token push to the hosted registry. Talking to your own registry uses yourREGISTRY_USERNAME/REGISTRY_PASSWORDsecrets 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
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
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
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 everything above and adds the missing pieces.
Comments
No comments yet. Start the discussion.