Files
at-container-registry/docs/appview.md
T
Evan JarrettandClaude Fable 5.1 56bde61555 appview: keep one part upload in flight while the next buffer fills
A large layer went up strictly one step at a time: fill 16MB from the
client, stop reading, fetch a part URL and PUT the part to S3, reset,
resume reading. While the part was in flight Docker sat on a full TCP
window; while the buffer filled S3 sat idle. Wall clock was receive
time plus send time.

The writer now hands a full buffer to a goroutine that does the hold
call and the PUT, and keeps filling a second buffer from the client.
When that one fills it waits for the previous part, takes its buffer
back, and hands the new one off. At most one part is in flight, so part
numbers and ETags stay ordered, and a blob that never fills a buffer
never allocates the second one. No network runs under the writer lock
on the happy path.

Peak memory for a large upload is now two buffers, 32MB. Both are
charged to the process budget through the existing accounting, the
second as it grows, and the budget floor rises to match so a large
upload can never be refused outright. The 512MB default holds sixteen.

A failed part records a sticky error, closes the writer, and aborts the
multipart from the goroutine that still holds the upload ID; the next
Write, hand-off, or Commit reports the cause. Commit verifies the digest
first, then waits for the flight, sends the final part, and completes.
Cancel waits for the flight, bounded, before aborting so the abort
cannot overtake a PUT that has not yet been issued its upload ID. The
sweeper refuses to reap a writer with a part in flight, since last
activity is only stamped when a part lands.

Tests observe the overlap directly: the fake S3 blocks the first PUT and
the second buffer's writes are asserted to return before it is released,
while the third buffer's writes block. Also covered: the one-part-late
error, Commit waiting, Cancel during flight, peak budget, the sweeper,
and concurrent Cancel and Write under the race detector. The
integration suite passed with a 72MB layer pushed through the pipeline
by three clients.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yf1ZVA7sXYhQNb9tCo1m5
2026-09-09 20:52:52 -05:00

11 KiB

ATCR AppView

The registry frontend component of ATCR (ATProto Container Registry)

Overview

AppView is the frontend server component of ATCR. It serves as the OCI-compliant registry API endpoint and web interface that Docker clients interact with when pushing and pulling container images.

AppView is the orchestration layer that:

  • Serves the OCI Distribution API V2 - Compatible with Docker, containerd, podman, and all OCI clients
  • Resolves ATProto identities - Converts handles (alice.bsky.social) and DIDs (did:plc:xyz123) to PDS endpoints
  • Routes manifests - Stores container image manifests as ATProto records in users' Personal Data Servers
  • Routes blobs - Proxies blob (layer) operations to hold services for S3-compatible storage
  • Provides web UI - Browse repositories, search images, view tags, track pull counts, manage stars, vulnerability scan results
  • Manages authentication - ATProto OAuth with device authorization flow, issues registry JWTs to Docker clients

The ATCR Ecosystem

AppView is the frontend of a multi-component architecture:

  1. AppView (this component) - Registry API + web interface
  2. Hold Service - Storage backend with embedded PDS for blob storage
  3. Credential Helper - Client-side tool for ATProto OAuth authentication

Data flow:

Docker Client → AppView (resolves identity) → User's PDS (stores manifest)
                    ↓
              Hold Service (stores blobs in S3/Storj/etc.)

Manifests (small JSON metadata) live in users' ATProto PDS, while blobs (large binary layers) live in hold services. AppView orchestrates the routing between these components.

When to Run Your Own AppView

Most users can simply use https://atcr.io - you don't need to run your own AppView.

Run your own AppView if you want to:

  • Host a private/organizational container registry with ATProto authentication
  • Run a public registry for a specific community
  • Customize the registry UI or policies
  • Maintain full control over registry infrastructure

Prerequisites:

  • A running Hold service (required for blob storage)
  • (Optional) Domain name with SSL/TLS certificates for production
  • (Optional) Access to ATProto Jetstream for real-time indexing

Quick Start

1. Build the Docker image

docker build -t atcr-appview:latest -f Dockerfile.appview .

This produces a ~30MB scratch image with a statically-linked binary.

2. Generate a config file

docker run --rm atcr-appview config init > config-appview.yaml

This creates a fully-commented YAML file with all available options and their defaults. You can also generate it from a local binary:

./bin/atcr-appview config init config-appview.yaml

3. Set the required field

Edit config-appview.yaml and set server.managed_holds to the list of hold DIDs this AppView manages. The first entry is used as the default blob-storage hold when a user has no hold selected:

server:
  managed_holds:
    - "did:web:127.0.0.1:8080"  # local dev
  # managed_holds:
  #   - "did:web:hold01.example.com"  # production

This is the only required configuration field. To find a hold's DID, visit its /.well-known/did.json endpoint. The env var equivalent is ATCR_SERVER_MANAGED_HOLDS (comma-separated list of DIDs).

For production, also set your public URL:

server:
  base_url: "https://registry.example.com"
  managed_holds:
    - "did:web:hold01.example.com"

4. Run

docker run -d \
  -v ./config-appview.yaml:/config.yaml:ro \
  -v atcr-data:/var/lib/atcr \
  -p 5000:5000 \
  atcr-appview serve --config /config.yaml

5. Verify

curl http://localhost:5000/v2/
# Should return: {}

curl http://localhost:5000/health
# Should return: {"status":"ok"}

Configuration

AppView uses YAML configuration with environment variable overrides. The generated config-appview.yaml is the canonical reference — every field is commented inline with its purpose and default value.

Config loading priority (highest wins)

  1. Environment variables (ATCR_ prefix)
  2. YAML config file (--config)
  3. Built-in defaults

Environment variable convention

YAML paths map to env vars with ATCR_ prefix and _ separators:

server.managed_holds     →  ATCR_SERVER_MANAGED_HOLDS (comma-separated)
server.base_url          →  ATCR_SERVER_BASE_URL
ui.database_path         →  ATCR_UI_DATABASE_PATH
jetstream.backfill_enabled → ATCR_JETSTREAM_BACKFILL_ENABLED

Config sections overview

Section Purpose Notes
server Listen address, public URL, managed holds, branding, blob upload limits Only managed_holds is required
ui Database path, theme, libSQL sync All have defaults; auto-creates DB on first run
auth JWT signing key/cert paths Auto-generated on first run
jetstream Real-time ATProto event streaming, backfill sync Runs automatically; backfill enabled by default
health Hold health check interval and cache TTL Sensible defaults (15m)
log_shipper Remote log shipping (Victoria, OpenSearch, Loki) Disabled by default
legal Terms/privacy page customization Optional

Blob upload memory

Each in-flight blob upload buffers up to 16MB in the AppView process, and a large upload holds two of those buffers at its peak (32MB), because one part is uploaded to S3 in the background while the next buffer fills; a blob small enough never to flush only ever holds one. Docker pushes several layers at once per client, so concurrent pushes are bounded by two server settings:

Field Default Purpose
upload_buffer_budget_mb 512 Process-wide ceiling on memory held in upload buffers. A push that would exceed it blocks until another upload finishes, which is backpressure on the Docker client rather than an error. Raised to 32MB (one writer's peak) if configured lower, since a smaller budget could never satisfy a single large upload.
upload_idle_timeout 1h How long an upload may go without a write before it is treated as abandoned.

A background sweeper runs every 5 minutes on every instance (it is deliberately not leased: the uploads it tracks are per-process). Anything idle past upload_idle_timeout is cancelled: its buffer and budget are released, its hold-side S3 multipart upload is aborted, and the client gets BLOB_UPLOAD_UNKNOWN if it ever comes back, which makes Docker restart the layer. Inactivity is the signal, not age, so a slow push that is still making progress is never reaped.

Auto-generated files

On first run (and each boot), AppView auto-generates these under /var/lib/atcr/:

File Purpose
ui.db SQLite database (OAuth sessions, stars, pull counts, device approvals, crypto keys)
auth/private-key.crt X.509 certificate regenerated every boot from the RSA key stored in ui.db

The RSA key (for registry JWT signing) and the P-256 key (for OAuth client authentication) are both stored in the crypto_keys table inside ui.db and generated on first run. The cert file is derived from the DB key on every boot so the distribution library can read it from disk.

Persist ui.db across restarts. Losing the database loses both crypto keys (invalidating all active sessions) as well as OAuth state and UI data. The auth/ directory is transient and recreated automatically each boot.

Deployment

Dockerfile.appview builds a minimal scratch image (~30MB) containing:

  • Static atcr-appview binary (CGO-enabled with embedded SQLite)
  • healthcheck binary for container health checks
  • CA certificates and timezone data

Port: 5000 (HTTP)

Volume: /var/lib/atcr (database; cert is regenerated each boot)

Health check: GET /health returns {"status":"ok"}

docker run -d \
  --name atcr-appview \
  -v ./config-appview.yaml:/config.yaml:ro \
  -v atcr-data:/var/lib/atcr \
  -p 5000:5000 \
  --health-cmd '/healthcheck http://localhost:5000/health' \
  --health-interval 30s \
  --restart unless-stopped \
  atcr-appview serve --config /config.yaml

Production with reverse proxy

AppView serves HTTP on port 5000. For production, put a reverse proxy in front for HTTPS termination. The repository includes a working Caddy + Docker Compose setup at deploy/docker-compose.prod.yml that runs AppView, Hold, and Caddy together with automatic TLS.

A minimal production compose override:

services:
  atcr-appview:
    image: atcr-appview:latest
    command: ["serve", "--config", "/config.yaml"]
    environment:
      ATCR_SERVER_BASE_URL: https://registry.example.com
      ATCR_SERVER_MANAGED_HOLDS: did:web:hold.example.com
    volumes:
      - ./config-appview.yaml:/config.yaml:ro
      - atcr-appview-data:/var/lib/atcr
    healthcheck:
      test: ["CMD", "/healthcheck", "http://localhost:5000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

volumes:
  atcr-appview-data:

Systemd (bare metal)

For non-Docker deployments, see the systemd service templates in deploy/upcloud/ which include security hardening (dedicated user, filesystem protection, private tmp).

Deployment Scenarios

Public Registry

Open to all ATProto users:

# config-appview.yaml
server:
  base_url: "https://registry.example.com"
  managed_holds:
    - "did:web:hold01.example.com"
jetstream:
  backfill_enabled: true

The linked hold service should have server.public: true and registration.allow_all_crew: true.

Private Organizational Registry

Restricted to crew members only:

# config-appview.yaml
server:
  base_url: "https://registry.internal.example.com"
  managed_holds:
    - "did:web:hold.internal.example.com"

The linked hold service should have server.public: false and registration.allow_all_crew: false, with an explicit registration.owner_did set to the organization's DID.

Local Development

# config-appview.yaml
log_level: debug
server:
  managed_holds:
    - "did:web:127.0.0.1:8080"
  test_mode: true  # allows HTTP for DID resolution

Run a hold service locally with Minio for S3-compatible storage. See hold.md for hold setup.

Web Interface

The AppView web UI provides:

  • Home page - Featured repositories and recent pushes
  • Repository pages - Tags, manifests, pull instructions, health status, vulnerability scan results
  • Search - Find repositories by owner handle or repository name
  • User profiles - View a user's repositories and starred images
  • Stars - Favorite repositories (requires login)
  • Pull counts - Image pull statistics
  • Multi-arch support - Platform-specific manifests (linux/amd64, linux/arm64, etc.)
  • Health indicators - Real-time hold service reachability
  • Device management - Approve and revoke Docker credential helper pairings
  • Settings - Choose default hold, view crew memberships, storage usage