1. Multiple registry domains + per-user domain preference
The biggest feature. The appview can serve several registry domains (e.g. buoy.cr, atcr.io),
and users can now pick which one shows up in their pull/push commands.
- Lexicon/record: adds registryDomain (and documents ociClient)
to the sailor profile (lexicons/.../profile.json, pkg/atproto/lexicon.go).
- DB: new registry_domain column on users (schema.sql + migration 0027),
with GetUserByDID/Handle reads, UpdateUserRegistryDomain writer,
and Jetstream caching it on profile updates (writes unconditionally so clearing propagates).
- UI/handlers: new UpdateRegistryDomainHandler + /api/profile/registry-domain route,
a <select> in the user settings panel (only shown when >1 domain configured), and resolveRegistryURL()
which falls back to the primary domain if the user's pref is stale/removed. Tests added for all of it.
2. default_hold_did removed → first managed_holds entry is the default
Consolidates two overlapping config fields into one. ServerConfig.DefaultHoldDID is gone;
PrimaryHoldDID() now returns managed_holds[0]. managed_holds is now REQUIRED.
Updated in config, validation, server wiring, test harness, example YAML, and the deploy template.
3. Admin long-running operations → generic background-job framework
New pkg/hold/admin/jobs.go introduces a reusable startJob/jobRegistry pattern
(a detached context.Background() job + a /admin/api/jobs/{key}/status polling endpoint).
This replaces the bespoke scan-backfill goroutine state machine, and now also wraps crew tier remap and crew import
all three previously looped synchronously on the request context and got 504'd/cancelled mid-run by the reverse proxy.
Forms switched from POST-redirect to htmx fragments (job_progress.html, job_result.html, crew_import_results.html)
the old crew_import_results.html page and scan_backfill_progress.html partial were deleted.
This is also captured as a new rule in CLAUDE.md.
4. Cascade-delete manifest on last-tag deletion
DeleteTagHandler now, after removing the last tag pointing to a digest, cascade-deletes the manifest itself
(PDS + DB + hold blob purge) — but only if it's not a child of a manifest list (multi-arch parent).
New GetTagDigest and ShouldCascadeDeleteManifest queries back it, plus cascade_delete_test.go.
Also switches tag rkey computation to the atproto.RepositoryTagToRKey helper.
5. Billing simplification
Drops the OwnerBadge config option (hold-owner supporter badge).
The user-profile template no longer special-cases an "owner" badge value (only "Captain").
Example tiers renamed to the nautical scheme (deckhand/bosun/quartermaster).
6. Build/deploy: go generate always runs via Make
make generate is now a phony target that always runs go generate ./... (regenerating cbor_gen, icon sprites, etc.),
and build-trixie depends on it. The deploy tooling (provision.go/update.go)
drops its own runGenerate calls since the Makefile handles it.
7. New cmd/firehose-tap tool (untracked)
A standalone CLI that subscribes to a com.atproto.sync.subscribeRepos endpoint and pretty-prints events,
with emphasis on Sync 1.1 compliance fields (per-op prev CIDs, commit prevData) and a --validate CI mode.
Fits with the recent "more sync1.1 compliant" commit.
ATCR UpCloud Deployment Guide
This guide walks you through deploying ATCR on UpCloud with Rocky Linux.
Architecture
- AppView (atcr.io) - OCI registry API + web UI
- Hold Service (hold01.atcr.io) - Presigned URL generator for blob storage
- Caddy - Reverse proxy with automatic HTTPS
- UpCloud Object Storage (blobs.atcr.io) - S3-compatible blob storage
Prerequisites
1. UpCloud Account
- Active UpCloud account
- Object Storage enabled
- Billing configured
2. Domain Names
You need three DNS records:
atcr.io(or your domain) - AppViewhold01.atcr.io- Hold serviceblobs.atcr.io- S3 storage (CNAME)
3. ATProto Account
- Bluesky/ATProto account
- Your DID (get from:
https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.bsky.social)
4. UpCloud Object Storage Bucket
Create an S3 bucket in UpCloud Object Storage:
- Go to UpCloud Console → Storage → Object Storage
- Create new bucket (e.g.,
atcr-blobs) - Note the region (e.g.,
us-chi1) - Generate access credentials (Access Key ID + Secret)
- Note the endpoint (e.g.,
s3.us-chi1.upcloudobjects.com)
Deployment Steps
Step 1: Configure DNS
Set up DNS records (using Cloudflare or your DNS provider):
Type Name Value Proxy
────────────────────────────────────────────────────────────────────────────
A atcr.io [your-upcloud-ip] ☁️ DISABLED
A hold01.atcr.io [your-upcloud-ip] ☁️ DISABLED
CNAME blobs.atcr.io atcr-blobs.us-chi1.upcloudobjects.com ☁️ DISABLED
IMPORTANT:
- DISABLE Cloudflare proxy (gray cloud, not orange) for all three domains
- Proxied connections break Docker registry protocol and presigned URLs
- You'll still get HTTPS via Caddy's Let's Encrypt integration
Wait for DNS propagation (5-30 minutes). Verify with:
dig atcr.io
dig hold01.atcr.io
dig blobs.atcr.io
Step 2: Create UpCloud Server
- Go to UpCloud Console → Servers → Deploy a new server
- Select location (match your S3 region if possible)
- Select Rocky Linux 9 operating system
- Choose plan (minimum: 2 GB RAM, 1 CPU)
- Configure hostname:
atcr - Enable IPv4 public networking
- Optional: Enable IPv6
- User data: Paste contents of
deploy/init-upcloud.sh- Update
ATCR_REPOvariable with your git repository URL - Or leave empty and manually copy files later
- Update
- Create SSH key or use password authentication
- Click Deploy
Step 3: Wait for Initialization
The init script will:
- Update system packages (~2-5 minutes)
- Install Docker and Docker Compose
- Configure firewall
- Clone repository (if ATCR_REPO configured)
- Create systemd service
- Create helper scripts
Monitor progress:
# SSH into server
ssh root@[your-upcloud-ip]
# Check cloud-init logs
tail -f /var/log/cloud-init-output.log
Wait for the completion message in the logs.
Step 4: Configure Environment
Edit the environment configuration:
# SSH into server
ssh root@[your-upcloud-ip]
# Edit environment file
cd /opt/atcr
nano .env
Required configuration:
# Domains
APPVIEW_DOMAIN=atcr.io
HOLD_DOMAIN=hold01.atcr.io
# Your ATProto DID
HOLD_OWNER=did:plc:your-did-here
# UpCloud S3 credentials
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
AWS_REGION=us-chi1
S3_BUCKET=atcr-blobs
# S3 endpoint (choose one):
# Option 1: Custom domain (recommended)
S3_ENDPOINT=https://blobs.atcr.io
# Option 2: Direct UpCloud endpoint
# S3_ENDPOINT=https://s3.us-chi1.upcloudobjects.com
# Public access (optional)
HOLD_PUBLIC=false # Set to true to allow anonymous pulls
Save and exit (Ctrl+X, Y, Enter).
Step 5: Start ATCR
# Start services
systemctl start atcr
# Check status
systemctl status atcr
# Verify containers are running
docker ps
You should see three containers:
atcr-caddyatcr-appviewatcr-hold
Step 6: Complete Hold OAuth Registration
The hold service needs to register itself with your PDS:
# Get OAuth URL from logs
/opt/atcr/get-hold-oauth.sh
Look for output like:
Visit this URL to authorize: https://bsky.social/oauth/authorize?...
- Copy the URL and open in your browser
- Log in with your ATProto account
- Authorize the hold service
- Return to terminal
The hold service will create records in your PDS:
io.atcr.hold- Hold definitionio.atcr.hold.crew- Your membership as captain
Verify registration:
docker logs atcr-hold | grep -i "success\|registered\|created"
Step 7: Test the Registry
Test 1: Check endpoints
# AppView (should return {})
curl https://atcr.io/v2/
# Hold service (should return {"status":"ok"})
curl https://hold01.atcr.io/health
Test 2: Configure Docker client
On your local machine:
# Install credential helper
# (Build from source or download release)
go install atcr.io/cmd/docker-credential-atcr@latest
# Configure Docker to use the credential helper
# Add to ~/.docker/config.json:
{
"credHelpers": {
"atcr.io": "atcr"
}
}
Test 3: Push a test image
# Tag an image
docker tag alpine:latest atcr.io/yourhandle/test:latest
# Push to ATCR
docker push atcr.io/yourhandle/test:latest
# Pull from ATCR
docker pull atcr.io/yourhandle/test:latest
Step 8: Monitor and Maintain
View logs
# All services
/opt/atcr/logs.sh
# Specific service
/opt/atcr/logs.sh atcr-appview
/opt/atcr/logs.sh atcr-hold
/opt/atcr/logs.sh atcr-caddy
# Or use docker directly
docker logs -f atcr-appview
Enable debug logging
Toggle debug logging at runtime without restarting the container:
# Enable debug logging (auto-reverts after 30 minutes)
docker kill -s SIGUSR1 atcr-appview
docker kill -s SIGUSR1 atcr-hold
# Manually disable before timeout
docker kill -s SIGUSR1 atcr-appview
When toggled, you'll see:
level=INFO msg="Log level changed" from=INFO to=DEBUG trigger=SIGUSR1 auto_revert_in=30m0s
Note: Despite the command name, docker kill -s SIGUSR1 does NOT stop the container. It sends a user-defined signal that the application handles to toggle debug mode.
Restart services
# Restart all
systemctl restart atcr
# Or use docker-compose
cd /opt/atcr
docker compose -f deploy/docker-compose.prod.yml restart
Rebuild after code changes
/opt/atcr/rebuild.sh
Update configuration
# Edit environment
nano /opt/atcr/.env
# Restart services
systemctl restart atcr
Architecture Details
Service Communication
Internet
↓
Caddy (443) ───────────┐
├─→ atcr-appview:5000 (Registry API + Web UI)
└─→ atcr-hold:8080 (Presigned URL generator)
↓
UpCloud S3 (blobs.atcr.io)
Data Flow: Push
1. docker push atcr.io/user/image:tag
2. AppView ← Docker client (manifest + blob metadata)
3. AppView → ATProto PDS (store manifest record)
4. Hold ← Docker client (request presigned URL)
5. Hold → UpCloud S3 API (generate presigned URL)
6. Hold → Docker client (return presigned URL)
7. UpCloud S3 ← Docker client (upload blob directly)
Data Flow: Pull
1. docker pull atcr.io/user/image:tag
2. AppView ← Docker client (get manifest)
3. AppView → ATProto PDS (fetch manifest record)
4. AppView → Docker client (return manifest with holdEndpoint)
5. Hold ← Docker client (request presigned URL)
6. Hold → UpCloud S3 API (generate presigned URL)
7. Hold → Docker client (return presigned URL)
8. UpCloud S3 ← Docker client (download blob directly)
Key insight: The hold service only generates presigned URLs. Actual data transfer happens directly between Docker clients and S3, minimizing bandwidth costs.
Troubleshooting
Issue: "Cannot connect to registry"
Check DNS:
dig atcr.io
dig hold01.atcr.io
Check Caddy logs:
docker logs atcr-caddy
Check firewall:
firewall-cmd --list-all
Issue: "Certificate errors"
Verify DNS is propagated:
curl -I https://atcr.io
Check Caddy is obtaining certificates:
docker logs atcr-caddy | grep -i certificate
Common causes:
- DNS not propagated (wait 30 minutes)
- Cloudflare proxy enabled (must be disabled)
- Port 80/443 blocked by firewall
Issue: "Presigned URLs fail"
Check S3 endpoint configuration:
docker exec atcr-hold env | grep S3
Verify custom domain CNAME:
dig blobs.atcr.io CNAME
Test S3 connectivity:
docker exec atcr-hold wget -O- https://blobs.atcr.io/
Common causes:
- Cloudflare proxy enabled on blobs.atcr.io
- S3_ENDPOINT misconfigured
- AWS credentials invalid
Issue: "Hold registration fails"
Check hold owner DID:
docker exec atcr-hold env | grep HOLD_OWNER
Verify OAuth flow:
/opt/atcr/get-hold-oauth.sh
Manual registration:
# Get fresh OAuth URL
docker restart atcr-hold
docker logs -f atcr-hold
Issue: "High bandwidth usage"
Presigned URLs should eliminate hold bandwidth. If seeing high usage:
Verify presigned URLs are enabled:
docker logs atcr-hold | grep -i presigned
Check S3 configuration:
docker exec atcr-hold env | grep S3_BUCKET
# Should show your S3 bucket name
Verify direct S3 access:
# Push should show 307 redirects in logs
docker logs -f atcr-hold
# Then push an image
Automatic Updates
# Install automatic updates
dnf install -y dnf-automatic
# Enable timer
systemctl enable --now dnf-automatic.timer
Monitoring
# Install monitoring tools
dnf install -y htop iotop nethogs
# Monitor resources
htop
# Monitor Docker
docker stats
Backups
Critical data to backup:
/opt/atcr/.env- Configuration- Docker volumes:
atcr-appview-data- Auth keys, UI database, OAuth tokenscaddy_data- TLS certificates
# Backup volumes
docker run --rm \
-v atcr-appview-data:/data \
-v /backup:/backup \
alpine tar czf /backup/atcr-appview-data.tar.gz /data
Scaling Considerations
Single Server (Current Setup)
- Suitable for: 100-1000 users
- Bottleneck: AppView CPU (manifest queries)
- Storage: Unlimited (S3)
Multi-Server (Future)
- Multiple AppView instances behind load balancer
- Shared Redis for hold cache (replace in-memory cache)
- PostgreSQL for UI database (replace SQLite)
- Multiple hold services (geo-distributed)
Support
- Documentation: https://tangled.org/evan.jarrett.net/at-container-registry
- Issues: https://tangled.org/evan.jarrett.net/at-container-registry/issues
- Bluesky: @evan.jarrett.net