- Python 95.8%
- Dockerfile 4.2%
| config | ||
| .DS_Store | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
| requirements.txt | ||
| token_server.py | ||
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:
docker login→ registry returns 401 with token endpoint URL- Docker client sends credentials to
/auth(token server) - Token server validates credentials against LDAP
- Token server returns a signed JWT with a
kidheader - Docker client presents JWT to registry
- Registry verifies JWT signature against the certificate
- 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:
- Create a proxy host for
registry.yourdomain.com - Details tab:
- Scheme:
http - Forward Hostname/IP: your registry container name or IP
- Forward Port:
5000
- Scheme:
- SSL tab:
- Request Let's Encrypt certificate
- Force SSL: yes
- Custom Locations tab:
- Add location:
/auth - Scheme:
http - Forward Hostname/IP: your auth container name (e.g.,
registry-auth-1) - Forward Port:
5001
- Add location:
- 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:5001andlocalhost: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
kidheader, or the cert doesn't match the signing key - Ensure the same
token-signing.crtis 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_ISSUERenv var must exactly matchissuerinconfig/registry.yml - Service mismatch: the
servicequery param in the token request must matchserviceinconfig/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) orrequest_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