No description
  • Python 95.8%
  • Dockerfile 4.2%
Find a file
2026-05-21 03:24:15 -07:00
config server 2026-05-21 03:15:21 -07:00
.DS_Store update readme 2026-05-21 03:24:15 -07:00
.gitignore server 2026-05-21 03:15:21 -07:00
docker-compose.yml server 2026-05-21 03:15:21 -07:00
Dockerfile server 2026-05-21 03:15:21 -07:00
README.md update readme 2026-05-21 03:24:15 -07:00
requirements.txt server 2026-05-21 03:15:21 -07:00
token_server.py server 2026-05-21 03:15:21 -07:00

Docker Registry with LDAP Token Auth

A self-hosted Docker container registry using token-based authentication backed by LDAP. Uses a minimal Python token server (~100 lines) instead of the buggy/unmaintained cesanta/docker_auth.

Architecture

                         ┌─────────────────┐
                         │  Reverse Proxy  │
                         │(NPM/Caddy/nginx)│
                         └────────┬────────┘
                                  │ TLS termination
                    ┌─────────────┼─────────────┐
                    │             │             │
                    ▼             ▼             │
              /v2/* routes   /auth route        │
                    │             │             │
         ┌──────────┴──┐   ┌─────┴──────┐     │
         │  Registry   │   │Token Server │     │
         │ (registry:2)│   │  (Python)   │     │
         │  port 5000  │   │  port 5001  │     │
         └──────────┬──┘   └─────┬───────┘     │
                    │             │             │
                    │        LDAP bind          │
                    │             │             │
                    │      ┌──────┴──────┐     │
                    │      │    LLDAP    │     │
                    │      │  port 3890  │     │
                    │      └─────────────┘     │
                    │                          │
              ┌─────┴─────┐                    │
              │  Volume   │                    │
              │(image data)│                    │
              └───────────┘                    │

Flow:

  1. docker login → registry returns 401 with token endpoint URL
  2. Docker client sends credentials to /auth (token server)
  3. Token server validates credentials against LDAP
  4. Token server returns a signed JWT with a kid header
  5. Docker client presents JWT to registry
  6. Registry verifies JWT signature against the certificate
  7. Push/pull proceeds

Prerequisites

  • Docker and Docker Compose
  • An LDAP server (tested with LLDAP)
  • A reverse proxy terminating TLS (Nginx Proxy Manager, Caddy, nginx, Traefik, etc.)
  • A domain name pointing to your server (e.g., registry.yourdomain.com)

Directory Structure

.
├── docker-compose.yml     # Registry + token server
├── Dockerfile             # Token server image
├── token_server.py        # Token auth server (Python/Flask)
├── requirements.txt       # Python dependencies
├── config/
│   └── registry.yml       # Registry configuration
├── certs/                  # NOT committed to git
│   ├── token-signing.key  # Private key (signs JWTs)
│   └── token-signing.crt  # Public cert (registry verifies JWTs)
└── .env                    # NOT committed to git

Setup

1. Generate Token Signing Certificate

These are used for JWT signing/verification only — not TLS. The reverse proxy handles TLS separately.

mkdir -p certs
openssl req -x509 -newkey rsa:2048 \
  -keyout certs/token-signing.key \
  -out certs/token-signing.crt \
  -days 3650 \
  -nodes \
  -subj "/CN=registry-token-signing"
chmod 600 certs/token-signing.key

2. Create .env

cat > .env << 'EOF'
LDAP_BIND_PASSWORD=your-ldap-service-account-password
REGISTRY_HTTP_SECRET=change-me-to-a-random-string
EOF
chmod 600 .env

Generate a random registry secret:

sed -i "s/change-me-to-a-random-string/$(openssl rand -hex 32)/" .env

3. Configure LDAP Connection

Edit docker-compose.yml and update the auth service environment variables:

Variable Description Example
LDAP_URL LDAP server URL ldap://lldap:3890
LDAP_BASE_DN Base DN for user search ou=people,dc=example,dc=com
LDAP_USER_FILTER Filter to find users ({username} is replaced) (uid={username})
LDAP_BIND_DN Service account DN for searching uid=admin,ou=people,dc=example,dc=com
LDAP_BIND_PASSWORD Service account password (from .env)
TOKEN_ISSUER Must match issuer in config/registry.yml docker-registry-auth
TOKEN_EXPIRY Token lifetime in seconds 900 (15 min)

4. Configure Registry

Edit config/registry.yml and set the realm to your public registry URL:

auth:
  token:
    realm: https://registry.yourdomain.com/auth   # Public URL of token endpoint
    service: docker-registry                       # Service name (must be consistent)
    issuer: docker-registry-auth                   # Must match TOKEN_ISSUER
    rootcertbundle: /certs/token-signing.crt       # Public cert for JWT verification

5. Configure Networking

The auth container needs to reach your LDAP server. If LLDAP is in a separate Docker Compose stack, add its network as external:

networks:
  lldap_network:
    external: true  # Replace with your LLDAP stack's network name

Find the network name:

docker network ls | grep lldap

6. Start

docker compose up -d --build

7. Configure Reverse Proxy

Your reverse proxy needs to route two paths to different backends:

Path Backend Port
/v2/* (default) registry 5000
/auth auth (token server) 5001

See the Reverse Proxy Configuration section below for detailed setup instructions for each proxy.

8. Test

# Should return 401 with WWW-Authenticate header
curl -I https://registry.yourdomain.com/v2/

# Should return a JWT token
curl -u "user:password" "https://registry.yourdomain.com/auth?service=docker-registry&scope=repository:test:pull"

# Login
docker login registry.yourdomain.com

# Push
docker pull alpine:latest
docker tag alpine:latest registry.yourdomain.com/test/alpine:latest
docker push registry.yourdomain.com/test/alpine:latest

# Pull
docker pull registry.yourdomain.com/test/alpine:latest

Reverse Proxy Configuration

All configurations assume TLS is terminated at the proxy. The critical settings for any proxy are:

  • No upload body size limit (image layers can be GBs)
  • Long timeouts (large layer pushes can take minutes)
  • Chunked transfer encoding enabled
  • Two routes: /auth → token server, everything else → registry

Nginx Proxy Manager (NPM)

Proxy Host setup:

  1. Create a proxy host for registry.yourdomain.com
  2. Details tab:
    • Scheme: http
    • Forward Hostname/IP: your registry container name or IP
    • Forward Port: 5000
  3. SSL tab:
    • Request Let's Encrypt certificate
    • Force SSL: yes
  4. Custom Locations tab:
    • Add location: /auth
    • Scheme: http
    • Forward Hostname/IP: your auth container name (e.g., registry-auth-1)
    • Forward Port: 5001
  5. Advanced tab (Custom Nginx Configuration):
    client_max_body_size 0;
    chunked_transfer_encoding on;
    proxy_read_timeout 900;
    proxy_send_timeout 900;
    proxy_request_buffering off;
    

Finding the container name for NPM:

docker ps --format '{{.Names}}' | grep auth

NPM and the registry containers must share a Docker network. Add NPM's network to your registry compose:

networks:
  npm_network:
    external: true  # Usually called something like nginx-proxy-manager_default

Caddy

Caddy handles TLS automatically via Let's Encrypt. No extra cert configuration needed.

Option A: Caddy in the same compose stack

Add to docker-compose.yml:

services:
  caddy:
    image: caddy:2
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - registry
      - auth

volumes:
  caddy-data:
  caddy-config:

Create Caddyfile:

registry.yourdomain.com {
    handle /auth* {
        reverse_proxy auth:5001
    }

    handle {
        reverse_proxy registry:5000 {
            transport http {
                response_header_timeout 900s
            }
        }
    }

    request_body {
        max_size 0
    }
}

Option B: Existing Caddy on the host

Add to your existing Caddyfile:

registry.yourdomain.com {
    handle /auth* {
        reverse_proxy localhost:5001
    }

    handle {
        reverse_proxy localhost:5000 {
            transport http {
                response_header_timeout 900s
            }
        }
    }

    request_body {
        max_size 0
    }
}

Ensure ports 5000 and 5001 are published on the host in your compose file.

Option C: Caddy in a separate compose stack (shared network)

registry.yourdomain.com {
    handle /auth* {
        reverse_proxy registry-auth-1:5001
    }

    handle {
        reverse_proxy registry-registry-1:5000 {
            transport http {
                response_header_timeout 900s
            }
        }
    }

    request_body {
        max_size 0
    }
}

Replace container names with actual names from docker ps --format '{{.Names}}'. Connect both stacks via a shared external network.

Nginx (manual)

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

    ssl_certificate     /etc/ssl/certs/registry.crt;
    ssl_certificate_key /etc/ssl/private/registry.key;

    # Required for Docker push (image layers can be very large)
    client_max_body_size 0;
    chunked_transfer_encoding on;

    # Timeouts for large layer uploads
    proxy_read_timeout 900;
    proxy_send_timeout 900;
    proxy_request_buffering off;

    # Token auth endpoint → token server
    location /auth {
        proxy_pass http://auth:5001/auth;
        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;
    }

    # Registry API → registry
    location / {
        proxy_pass http://registry: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;
    }
}

# HTTP → HTTPS redirect
server {
    listen 80;
    server_name registry.yourdomain.com;
    return 301 https://$host$request_uri;
}

Replace auth:5001 and registry:5000 with the appropriate hostnames:

  • If nginx is in the same compose stack: use service names (auth, registry)
  • If nginx is on the host: use localhost:5001 and localhost:5000 (with ports published)
  • If nginx is in a separate stack: use full container names on a shared network

Access Control

The token server currently grants all authenticated users push and pull access to any repository. To add per-user or per-repo restrictions, modify the auth() function in token_server.py:

# Example: only allow specific users to push
PUSH_USERS = {"admin", "ci-bot", "deploy"}

@app.route("/auth")
def auth():
    # ... authentication code ...

    # Restrict push access
    if "push" in scope["actions"] and username not in PUSH_USERS:
        scope["actions"] = ["pull"]

    access = [scope] if scope["name"] else []
    token = make_token(access, subject=username)
    return jsonify({"token": token, "expires_in": TOKEN_EXPIRY})

Tag Retention / Garbage Collection

Delete old tags then reclaim disk space:

# Delete a specific tag's manifest
DIGEST=$(curl -s -u user:pass \
  -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
  -I "https://registry.yourdomain.com/v2/myapp/manifests/old-tag" \
  | grep docker-content-digest | awk '{print $2}' | tr -d '\r')

curl -s -u user:pass -X DELETE \
  "https://registry.yourdomain.com/v2/myapp/manifests/$DIGEST"

# Garbage collect (reclaim disk — requires stopping the registry briefly)
docker compose stop registry
docker compose run --rm registry bin/registry garbage-collect /etc/docker/registry/config.yml
docker compose start registry

Automate with cron (weekly):

0 3 * * 0  cd /opt/registry && ./prune-tags.sh && docker compose stop registry && docker compose run --rm registry bin/registry garbage-collect /etc/docker/registry/config.yml && docker compose start registry

LLDAP-Specific Notes

  • LLDAP uses uid=username,ou=people,dc=... for bind DNs
  • There are no custom OUs — all users live under ou=people
  • The admin account bind DN is uid=admin,ou=people,dc=...
  • Port 3890 is LDAP (plain), port 6360 is LDAPS (TLS)
  • If using plain LDAP between containers on the same Docker network, TLS between them is unnecessary (traffic never leaves the host)

Troubleshooting

"unable to get token signing key" in registry logs

  • The JWT is missing the kid header, or the cert doesn't match the signing key
  • Ensure the same token-signing.crt is mounted in both the auth and registry containers
  • Regenerate certs and restart both services

"authentication failed" from token server

  • LDAP credentials are wrong — test from inside the auth container:
    docker compose exec auth python -c "
    import ldap3
    server = ldap3.Server('ldap://lldap:3890')
    conn = ldap3.Connection(server, 'uid=YOUR_USER,ou=people,dc=YOUR,dc=BASE,dc=DN', 'PASSWORD', auto_bind=True)
    print('OK')
    conn.unbind()
    "
    

502 Bad Gateway on /auth

  • Reverse proxy can't reach the token server container
  • Verify the container name: docker ps --format '{{.Names}}' | grep auth
  • Verify shared network: both proxy and auth must be on the same Docker network

"invalid token" in registry logs

  • Issuer mismatch: TOKEN_ISSUER env var must exactly match issuer in config/registry.yml
  • Service mismatch: the service query param in the token request must match service in config/registry.yml

docker login returns 401 even with correct credentials

  • Check registry logs for "unable to get token signing key" — this means the cert is wrong
  • Verify the token endpoint is reachable: curl -u user:pass "https://registry.yourdomain.com/auth?service=docker-registry&scope=repository:test:pull"

Large pushes timeout

  • Set client_max_body_size 0 (nginx) or request_body { max_size 0 } (Caddy)
  • Increase proxy timeouts to 900s+

LDAP connection errors (nil pointer panic with cesanta/docker_auth)

  • Don't use cesanta/docker_auth — it's unmaintained and crashes on LDAP errors. Use this token server instead.

Security

  • certs/token-signing.key — anyone with this key can forge registry tokens. Protect it (chmod 600).
  • .env — contains the LDAP bind password. Restrict permissions (chmod 600).
  • The token server runs plain HTTP internally. TLS is terminated at the reverse proxy — this is fine as long as traffic between the proxy and token server stays on a private Docker network.
  • Consider not publishing ports 5000/5001 on the host if your reverse proxy is on the same Docker network. Only the proxy needs to reach them.
  • Rotate the token signing cert periodically (update both containers, restart both).

Files Not to Commit

.gitignore:

certs/
.env
__pycache__/
*.pyc