Docker container registry service for Deploys.app, implementing the OCI Distribution Spec.
- Storage: Google Cloud Storage (blobs and manifests)
- Database: PostgreSQL (repository, manifest, tag, and blob metadata)
- Auth: Delegated to the Deploys.app API (
api.deploys.app)
All configuration is via environment variables.
| Variable | Required | Default | Description |
|---|---|---|---|
DB_URL |
yes | — | PostgreSQL connection string |
BUCKET_NAME |
yes | — | GCS bucket name |
PORT |
no | 8080 |
HTTP listen port |
LOG_LEVEL |
no | INFO |
Log level (DEBUG, INFO, WARN, ERROR) |
INTERNAL_SECRET |
no | — | Bearer token for /internal/* endpoints |
The service authenticates with Google Cloud using Application Default Credentials.
DB_URL=postgres://user:pass@host/db \
BUCKET_NAME=my-registry-bucket \
go run .docker build -t registry .
docker run -e DB_URL=... -e BUCKET_NAME=... registryApply the schema before first run:
psql $DB_URL -f schema.sql| Table | Description |
|---|---|
repositories |
Repository metadata (name, namespace, created_at) |
manifests |
OCI manifests per repository |
tags |
Tag → manifest digest mappings |
blobs |
Blob metadata including size |
manifest_blobs |
Manifest → blob references (rebuilt by indexManifests) |
project_storage_usage |
Pre-calculated total blob storage per project namespace (updated by calculateProjectStorage) |
Implements the OCI Distribution Spec v1. Compatible with docker, crane, skopeo, and any standard OCI client.
Authentication uses HTTP Basic Auth. Credentials are forwarded to the Deploys.app API to verify project permissions:
registry.pull— required forGET/HEADregistry.push— required forPOST/PUT/PATCH/DELETE
All management endpoints accept POST with Content-Type: application/json and return {"ok": true, "result": {...}} or {"ok": false, "error": {"message": "..."}}.
List repositories in a project namespace.
Request:
{ "project": "my-project" }Response:
{
"ok": true,
"result": {
"items": [
{ "name": "my-image", "createdAt": "2024-01-15T00:00:00Z" }
]
}
}Requires registry.list permission.
Get repository info and total blob storage size.
Request:
{ "project": "my-project", "repository": "my-image" }Response:
{
"ok": true,
"result": {
"name": "my-image",
"size": 1073741824,
"createdAt": "2024-01-15T00:00:00Z"
}
}Requires registry.get permission.
List tags for a repository with their digest and creation time.
Request:
{ "project": "my-project", "repository": "my-image" }Response:
{
"ok": true,
"result": {
"name": "my-image",
"items": [
{ "tag": "latest", "digest": "sha256:abc123...", "createdAt": "2024-01-15T00:00:00Z" }
]
}
}Requires registry.get permission.
List manifests for a repository with their digest and creation time.
Request:
{ "project": "my-project", "repository": "my-image" }Response:
{
"ok": true,
"result": {
"name": "my-image",
"items": [
{ "digest": "sha256:abc123...", "createdAt": "2024-01-15T00:00:00Z" }
]
}
}Requires registry.get permission.
Get the pre-calculated total blob storage used across all repositories in a project. The value is updated once per day by the calculateProjectStorage scheduler job. Returns size: 0 with no updatedAt if the job has not run yet.
Request:
{ "project": "my-project" }Response:
{
"ok": true,
"result": {
"size": 1073741824,
"updatedAt": "2024-01-15T03:00:00Z"
}
}Requires registry.get permission.
Delete a single manifest by digest, including all tags that point to it and their GCS objects. Blobs referenced by the manifest are not deleted immediately — they are cleaned up by the blob GC.
Request:
{ "project": "my-project", "repository": "my-image", "digest": "sha256:abc123..." }Response:
{ "ok": true }Requires registry.push permission.
Delete a repository and all its data — manifests, tags, blobs, and the corresponding GCS objects. The operation continues to completion even if the client disconnects or the request times out.
Request:
{ "project": "my-project", "repository": "my-image" }Response:
{ "ok": true }Requires registry.push permission.
Remove a single tag from a repository.
Request:
{ "project": "my-project", "repository": "my-image", "tag": "latest" }Response:
{ "ok": true }Requires registry.push permission.
Reads every manifest that has no manifest_blobs rows yet, fetches its content from GCS, and records which blobs it references. Runs as part of POST /internal/runAll.
Protected by Authorization: Bearer <INTERNAL_SECRET>. If INTERNAL_SECRET is not set the check is skipped (local dev only).
Returns 204 No Content on success.
To trigger manually:
curl -X POST https://registry.deploys.app/internal/indexManifests \
-H "Authorization: Bearer <INTERNAL_SECRET>"Deletes blobs that are not referenced by any manifest and are older than 1 day. The 1-day grace period covers blobs that have been uploaded but whose manifest push is still in flight. Runs as part of POST /internal/runAll.
Returns 204 No Content on success. Protected by the same INTERNAL_SECRET bearer token.
To trigger manually:
curl -X POST https://registry.deploys.app/internal/runBlobGC \
-H "Authorization: Bearer <INTERNAL_SECRET>"Calculates the total blob storage used by each project namespace and stores the result in the project_storage_usage table. Results are served via POST /api/getProjectStorage.
Because the blobs table can be very large, this is designed to run once per day rather than on every API request.
Returns 204 No Content on success. Protected by the same INTERNAL_SECRET bearer token.
To trigger manually:
curl -X POST https://registry.deploys.app/internal/calculateProjectStorage \
-H "Authorization: Bearer <INTERNAL_SECRET>"Runs all scheduled jobs sequentially in a single HTTP call:
indexManifestsrunBlobGCcalculateProjectStorage
Use this as the single Cloud Scheduler target so only one scheduler job is needed. If any step fails the endpoint returns 500 immediately (remaining steps are skipped) and the error is logged.
Returns 204 No Content on success. Protected by the same INTERNAL_SECRET bearer token.
Replace the individual per-job scheduler entries with a single job targeting /internal/runAll:
gcloud scheduler jobs create http registry-run-all \
--location=asia-southeast1 \
--schedule="0 2 * * *" \
--uri="https://registry.deploys.app/internal/runAll" \
--http-method=POST \
--headers="Authorization=Bearer <INTERNAL_SECRET>" \
--attempt-deadline=30m \
--time-zone="UTC"To update the schedule or secret on an existing job:
gcloud scheduler jobs update http registry-run-all \
--location=asia-southeast1 \
--schedule="0 2 * * *" \
--headers="Authorization=Bearer <INTERNAL_SECRET>"To trigger immediately (e.g. after initial deploy):
gcloud scheduler jobs run registry-run-all \
--location=asia-southeast1Imports existing metadata from Cloudflare D1 into PostgreSQL.
CLOUDFLARE_API_TOKEN=... \
CLOUDFLARE_ACCOUNT_ID=... \
DB_URL=postgres://... \
go run ./migrate/d1topg/| Variable | Required | Default | Description |
|---|---|---|---|
CLOUDFLARE_API_TOKEN |
yes | — | Cloudflare API token |
CLOUDFLARE_ACCOUNT_ID |
yes | — | Cloudflare account ID |
DB_URL |
yes | — | PostgreSQL connection string |
D1_DATABASE_ID |
no | 67b907e7-9d0d-4846-851e-8e7da80acbad |
D1 database ID |
Migrates all four tables (repositories, manifests, tags, blobs) in dependency order, in batches of 1000 rows. Safe to re-run — rows that already exist are skipped.
Copies all objects from Cloudflare R2 to Google Cloud Storage, preserving content-type and docker-content-digest metadata. For tag-addressed manifests the SHA256 digest is computed on the fly. Temporary upload objects (_uploads/) are skipped.
R2_ACCOUNT_ID=... \
R2_ACCESS_KEY_ID=... \
R2_SECRET_ACCESS_KEY=... \
BUCKET_NAME=my-gcs-bucket \
go run ./migrate/r2togcs/| Variable | Required | Default | Description |
|---|---|---|---|
R2_ACCOUNT_ID |
yes | — | Cloudflare account ID |
R2_ACCESS_KEY_ID |
yes | — | R2 access key ID |
R2_SECRET_ACCESS_KEY |
yes | — | R2 secret access key |
BUCKET_NAME |
yes | — | GCS destination bucket name |
R2_BUCKET |
no | deploys-registry |
R2 source bucket name |
WORKERS |
no | 8 |
Number of parallel copy workers |
Safe to re-run — objects already present in GCS are skipped.