A lightweight private Docker image platform built on top of CNCF Distribution (registry:3.1.0), Cloudflare R2 (or filesystem for dev), Go API Server, Postgres, Redis, and a Vite React console.
Docker CLI / CI / CD
|
| docker login / push / pull
v
Host Nginx (HTTPS, optional)
|
+-- registry.example.com -> registry:3.1.0 (Token Auth, R2/FS storage)
|
+-- console.example.com -> Go API Server + bundled Console SPA
|
+-- Postgres (business data)
+-- Redis (cache, rate limit)
+-- Registry HTTP API (webhook, tag delete)
- Docker Desktop (macOS/Windows) or Docker Engine (Linux)
- For macOS: configure Docker Engine to accept the insecure registry:
{
"insecure-registries": ["localhost:5000"]
}(Settings -> Docker Engine -> edit JSON -> Apply & Restart)
bash scripts/generate-auth-cert.shThis creates registry/certs/auth.key, registry/certs/auth.crt, and registry/certs/jwks.json.
If jwks.json is missing, the API server generates it from auth.crt on startup.
cp .env.example .env
# Review and update values if needed. For local dev, defaults are fine.docker compose -f docker-compose.dev.yml up -d --buildServices will be available at:
- Registry:
http://localhost:5000 - Console:
http://localhost:4173 - API:
http://localhost:3000
The development Compose file intentionally keeps the Console on :4173; it
uses http://localhost:${API_PORT:-3000} as its API base. Production instead
serves the bundled Console from the API port.
curl -I http://localhost:5000/v2/Expected: 401 Unauthorized with WWW-Authenticate: Bearer ...
Open http://localhost:4173 in your browser.
Default admin credentials (from .env):
- Email:
[email protected] - Password:
change_me_admin_password
- Go to Projects -> New Project -> name:
demo - Go to Robot Tokens -> New Token
- Project:
demo - Token Name:
ci - Permissions: check
pullandpush
- Project:
- Copy the token (shown only once).
# Login
docker login localhost:5000
# Username: robot$demo-ci
# Password: <paste token>
# Push test
docker pull alpine:latest
docker tag alpine:latest localhost:5000/demo/alpine:latest
docker push localhost:5000/demo/alpine:latest
# Pull test
docker rmi localhost:5000/demo/alpine:latest
docker pull localhost:5000/demo/alpine:latestGo to Projects -> demo -> click repository alpine.
You should see the latest tag with digest, size, and pushed time.
In production, api/Dockerfile builds the Console and copies the resulting
static files into the final Go image. The browser and API therefore share one
origin; no registry-console container or CONSOLE_API_URL is needed at
runtime. Node is used only during docker compose build.
Create a Cloudflare R2 bucket and API token with Object Read & Write permissions.
Record:
R2_ACCOUNT_IDR2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEYR2_BUCKET_NAME
Use certbot or any CA on your host machine. Configure your host nginx (or another reverse proxy) to terminate TLS and proxy to the Docker services:
registry.example.com -> 127.0.0.1:5000
console.example.com -> 127.0.0.1:3000 (API + console SPA)
See nginx.example.conf for a reference nginx configuration.
bash scripts/generate-auth-cert.shcp .env.example .envEdit .env and replace all change_me_* placeholders with strong secrets.
Set REGISTRY_PUBLIC_URL and CONSOLE_ORIGIN to your real public URLs.
The Console calls its API through same-origin /api paths, so remove any old
CONSOLE_API_URL value from your production .env.
registry/config.yml is a template rendered at container startup via envsubst
— all values (R2 credentials, domains, secrets) come from .env, so there is
nothing to edit in the YAML itself. If you use different variable names, keep
auth.token.realm pointing to your public API token endpoint.
docker compose up -d --build --remove-orphans.
├── docker-compose.yml # Production stack
├── docker-compose.dev.yml # Dev override (filesystem storage, HTTP)
├── .env.example # Environment template
├── nginx.example.conf # Reference nginx config for host
├── registry/
│ ├── config.yml # Production registry config (R2)
│ ├── config.dev.yml # Dev registry config (filesystem)
│ └── certs/ # auth.key, auth.crt, jwks.json
├── scripts/
│ ├── generate-auth-cert.sh
│ ├── bootstrap-dev.sh
│ ├── backup-postgres.sh
│ └── gc.sh
├── api/ # Go API Server
│ ├── Dockerfile
│ ├── go.mod
│ └── cmd/server/main.go
│ └── internal/...
└── console/ # Vite React Console
├── Dockerfile
├── package.json
└── src/...
- Registry:3.1.0: Uses
/etc/distribution/config.yml(v3 path). - Storage: Production uses R2 S3-compatible API; dev uses filesystem.
- Auth: Go API Server issues RS256 JWTs for registry token auth. Console uses HMAC session cookies.
- Robot Tokens: Username format
robot$<project>-<name>. Password is a 32-byte random hex string, bcrypt-hashed in DB (legacy SHA-256 hashes still verify). Only shown once on creation. Scoped strictly to their own project. - Webhook: Registry pushes events to Go API Server, which upserts projects/repositories/tags into Postgres.
- Delete tag: Console triggers registry manifest delete + Postgres soft delete.
- GC: Manual only in MVP. Use
scripts/gc.shas a guide.
- Replace all
change_me_*secrets in.env - Use real TLS certificates in production (host nginx)
- Restrict R2 API token to the registry bucket only
- Do not expose Postgres/Redis ports publicly (services bind to 127.0.0.1)
- Rotate
auth.keyperiodically (requires reconfiguring registry) - Enable 2FA for console users (future enhancement)
GET /api/registry/token?service=...&scope=...
Authorization: Basic base64(username:password)
POST /api/auth/login
POST /api/auth/logout
GET /api/me
GET /api/projects
POST /api/projects
GET /api/projects/:project
PATCH /api/projects/:project
DELETE /api/projects/:project
GET /api/projects/:project/members
POST /api/projects/:project/members
DELETE /api/projects/:project/members/:userId
GET /api/projects/:project/repositories
GET /api/projects/:project/repositories/:repo
GET /api/projects/:project/repositories/:repo/tags
GET /api/projects/:project/repositories/:repo/tags/:tag
DELETE /api/projects/:project/repositories/:repo/tags/:tag
GET /api/robot-tokens
POST /api/robot-tokens
DELETE /api/robot-tokens/:id
GET /api/users
POST /api/users
DELETE /api/users/:id
GET /api/audit-logs
POST /internal/registry/events
Authorization: Bearer <WEBHOOK_SECRET>
MIT