···11+# ATCR AppView Configuration
22+# Copy this file to .env.appview and fill in your values
33+# Load with: source .env.appview && ./bin/atcr-appview serve
44+55+# ==============================================================================
66+# Server Configuration
77+# ==============================================================================
88+99+# HTTP listen address (default: :5000)
1010+ATCR_HTTP_ADDR=:5000
1111+1212+# Debug listen address (default: :5001)
1313+# ATCR_DEBUG_ADDR=:5001
1414+1515+# Base URL for the AppView service (REQUIRED for production)
1616+# Used to generate OAuth redirect URIs and JWT realms
1717+# Development: Auto-detected from ATCR_HTTP_ADDR (e.g., http://127.0.0.1:5000)
1818+# Production: Set to your public URL (e.g., https://atcr.io)
1919+# ATCR_BASE_URL=http://127.0.0.1:5000
2020+2121+# Service name (used for JWT service/issuer fields)
2222+# Default: Derived from base URL hostname, or "atcr.io"
2323+# ATCR_SERVICE_NAME=atcr.io
2424+2525+# ==============================================================================
2626+# Storage Configuration
2727+# ==============================================================================
2828+2929+# Default hold service endpoint for users without their own storage (REQUIRED)
3030+# Users with a sailor profile defaultHold setting will override this
3131+# Docker: Use container name (http://atcr-hold:8080)
3232+# Local dev: Use localhost (http://127.0.0.1:8080)
3333+ATCR_DEFAULT_HOLD=http://127.0.0.1:8080
3434+3535+# ==============================================================================
3636+# Authentication Configuration
3737+# ==============================================================================
3838+3939+# Path to JWT signing private key (auto-generated if missing)
4040+# Default: /var/lib/atcr/auth/private-key.pem
4141+# ATCR_AUTH_KEY_PATH=/var/lib/atcr/auth/private-key.pem
4242+4343+# Path to JWT signing certificate (auto-generated if missing)
4444+# Default: /var/lib/atcr/auth/private-key.crt
4545+# ATCR_AUTH_CERT_PATH=/var/lib/atcr/auth/private-key.crt
4646+4747+# JWT token expiration in seconds (default: 300 = 5 minutes)
4848+# ATCR_TOKEN_EXPIRATION=300
4949+5050+# ==============================================================================
5151+# UI Configuration
5252+# ==============================================================================
5353+5454+# Enable web UI (default: true)
5555+# Set to "false" to disable web interface and run registry-only
5656+ATCR_UI_ENABLED=true
5757+5858+# SQLite database path for UI data (sessions, stars, pull counts, etc.)
5959+# Default: /var/lib/atcr/ui.db
6060+# ATCR_UI_DATABASE_PATH=/var/lib/atcr/ui.db
6161+6262+# ==============================================================================
6363+# Logging Configuration
6464+# ==============================================================================
6565+6666+# Log level: debug, info, warn, error (default: info)
6767+# ATCR_LOG_LEVEL=info
6868+6969+# Log formatter: text, json (default: text)
7070+# ATCR_LOG_FORMATTER=text
7171+7272+# ==============================================================================
7373+# Jetstream Configuration (ATProto event streaming)
7474+# ==============================================================================
7575+7676+# Jetstream WebSocket URL for real-time ATProto events
7777+# Default: wss://jetstream2.us-west.bsky.network/subscribe
7878+# JETSTREAM_URL=wss://jetstream2.us-west.bsky.network/subscribe
7979+8080+# Enable backfill worker to sync historical records (default: false)
8181+# Set to "true" to enable periodic syncing of ATProto records
8282+# ATCR_BACKFILL_ENABLED=true
8383+8484+# ATProto relay endpoint for backfill sync API
8585+# Default: https://relay1.us-east.bsky.network
8686+# ATCR_RELAY_ENDPOINT=https://relay1.us-east.bsky.network
8787+8888+# Backfill interval (default: 1h)
8989+# Examples: 30m, 1h, 2h, 24h
9090+# ATCR_BACKFILL_INTERVAL=1h
+48-23
CLAUDE.md
···3131# Or use docker-compose
3232docker-compose up -d
33333434-# Run locally (AppView)
3535-export ATPROTO_DID=did:plc:your-did
3636-export ATPROTO_ACCESS_TOKEN=your-token
3737-./atcr-appview serve config/config.yml
3434+# Run locally (AppView) - configure via env vars (see .env.appview.example)
3535+export ATCR_HTTP_ADDR=:5000
3636+export ATCR_DEFAULT_HOLD=http://127.0.0.1:8080
3737+./bin/atcr-appview serve
3838+3939+# Or use .env file:
4040+cp .env.appview.example .env.appview
4141+# Edit .env.appview with your settings
4242+source .env.appview
4343+./bin/atcr-appview serve
4444+4545+# Legacy mode (still supported):
4646+# ./bin/atcr-appview serve config/config.yml
38473939-# Run hold service (configure via env vars - see .env.example)
4848+# Run hold service (configure via env vars - see .env.hold.example)
4049export HOLD_PUBLIC_URL=http://127.0.0.1:8080
4150export STORAGE_DRIVER=filesystem
4251export STORAGE_ROOT_DIR=/tmp/atcr-hold
4352export HOLD_OWNER=did:plc:your-did-here
4444-./atcr-hold
5353+./bin/atcr-hold
4554# Check logs for OAuth URL, visit in browser to complete registration
4655```
4756···433442434443### Configuration
435444436436-**AppView configuration** (`config/config.yml`):
437437-- S3 bucket settings under `storage.s3`
438438-- ATProto middleware under `middleware.repository`
439439-- Name resolver under `middleware.registry`
440440-- Default storage endpoint: `middleware.registry.options.default_storage_endpoint`
441441-- Auth token signing keys and expiration
442442-- Database path: `db.path` (SQLite database location)
443443-- Jetstream endpoint: `jetstream.endpoint` (for ATProto event streaming)
445445+**AppView configuration** (environment variables):
446446+447447+Both AppView and Hold service follow the same pattern: **zero config files, all configuration via environment variables**.
448448+449449+See `.env.appview.example` for all available options. Key environment variables:
450450+451451+**Server:**
452452+- `ATCR_HTTP_ADDR` - HTTP listen address (default: `:5000`)
453453+- `ATCR_BASE_URL` - Public URL for OAuth/JWT realm (auto-detected in dev)
454454+- `ATCR_DEFAULT_HOLD` - Default hold endpoint for blob storage (REQUIRED)
455455+456456+**Authentication:**
457457+- `ATCR_AUTH_KEY_PATH` - JWT signing key path (default: `/var/lib/atcr/auth/private-key.pem`)
458458+- `ATCR_TOKEN_EXPIRATION` - JWT expiration in seconds (default: 300)
459459+460460+**UI:**
461461+- `ATCR_UI_ENABLED` - Enable web interface (default: true)
462462+- `ATCR_UI_DATABASE_PATH` - SQLite database path (default: `/var/lib/atcr/ui.db`)
463463+464464+**Jetstream:**
465465+- `JETSTREAM_URL` - ATProto event stream URL
466466+- `ATCR_BACKFILL_ENABLED` - Enable periodic sync (default: false)
467467+468468+**Legacy:** `config/config.yml` is still supported but deprecated. Use environment variables instead.
444469445470**Hold Service configuration** (environment variables):
446446-- Storage driver config via env vars: `STORAGE_DRIVER`, `AWS_*`, `S3_*`
447447-- Authorization: Based on PDS records (`hold.public`, crew records)
448448-- Server settings: `HOLD_SERVER_ADDR`, `HOLD_PUBLIC_URL`, `HOLD_PUBLIC`
449449-- Auto-registration: `HOLD_OWNER` (optional)
471471+472472+See `.env.hold.example` for all available options. Key environment variables:
473473+- `HOLD_PUBLIC_URL` - Public URL of hold service (REQUIRED)
474474+- `STORAGE_DRIVER` - Storage backend (s3, filesystem)
475475+- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials
476476+- `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration
477477+- `HOLD_PUBLIC` - Allow public reads (default: false)
478478+- `HOLD_OWNER` - DID for auto-registration (optional)
450479451480**Credential Helper**:
452481- Token storage: `~/.atcr/oauth-token.json`
453482- Contains: access token, refresh token, DPoP key (PEM), DID, handle
454454-455455-Environment variables:
456456-- `ATPROTO_DID`: DID for authentication with PDS (AppView only)
457457-- `ATPROTO_ACCESS_TOKEN`: Access token for PDS operations (AppView only)
458483459484### Development Notes
460485···524549- Queries in `pkg/appview/db/queries.go`
525550- Stores for OAuth, devices, sessions in separate files
526551- Run migrations automatically on startup
527527-- Database path configurable via config.yml
552552+- Database path configurable via `ATCR_UI_DATABASE_PATH` env var
528553529554**Adding web UI features**:
530555- Add handler in `pkg/appview/handlers/`
+2-8
Dockerfile
Dockerfile.appview
···3131# Copy binary from builder
3232COPY --from=builder /build/atcr-appview .
33333434-# Copy default configuration
3535-COPY config/config.yml /etc/atcr/config.yml
3636-3734# Create directories for storage
3835RUN mkdir -p /var/lib/atcr/blobs /var/lib/atcr/auth
39364037# Expose ports
4138EXPOSE 5000 5001
42394343-# Set environment variables
4444-ENV ATCR_CONFIG=/etc/atcr/config.yml
4545-4640# OCI image annotations
4741LABEL org.opencontainers.image.title="ATCR AppView" \
4842 org.opencontainers.image.description="ATProto Container Registry - OCI-compliant registry using AT Protocol for manifest storage" \
···5347 org.opencontainers.image.version="0.1.0" \
5448 io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4"
55495656-# Run the AppView
5050+# Run the AppView (no config file - uses environment variables)
5751ENTRYPOINT ["/app/atcr-appview"]
5858-CMD ["serve", "/etc/atcr/config.yml"]
5252+CMD ["serve"]
+213
cmd/appview/config.go
···11+package main
22+33+import (
44+ "fmt"
55+ "net/url"
66+ "os"
77+ "strconv"
88+ "time"
99+1010+ "github.com/distribution/distribution/v3/configuration"
1111+)
1212+1313+// loadConfigFromEnv builds a complete configuration from environment variables
1414+// This follows the same pattern as the hold service (no config files, only env vars)
1515+func loadConfigFromEnv() (*configuration.Configuration, error) {
1616+ config := &configuration.Configuration{}
1717+1818+ // Version
1919+ config.Version = configuration.MajorMinorVersion(0, 1)
2020+2121+ // Logging
2222+ config.Log = buildLogConfig()
2323+2424+ // HTTP server
2525+ httpConfig, err := buildHTTPConfig()
2626+ if err != nil {
2727+ return nil, fmt.Errorf("failed to build HTTP config: %w", err)
2828+ }
2929+ config.HTTP = httpConfig
3030+3131+ // Storage (fake in-memory placeholder - all real storage is proxied)
3232+ config.Storage = buildStorageConfig()
3333+3434+ // Middleware (ATProto resolver)
3535+ defaultHold := os.Getenv("ATCR_DEFAULT_HOLD")
3636+ if defaultHold == "" {
3737+ return nil, fmt.Errorf("ATCR_DEFAULT_HOLD is required")
3838+ }
3939+ config.Middleware = buildMiddlewareConfig(defaultHold)
4040+4141+ // Auth
4242+ baseURL := getBaseURL(httpConfig.Addr)
4343+ authConfig, err := buildAuthConfig(baseURL)
4444+ if err != nil {
4545+ return nil, fmt.Errorf("failed to build auth config: %w", err)
4646+ }
4747+ config.Auth = authConfig
4848+4949+ // Health checks
5050+ config.Health = buildHealthConfig()
5151+5252+ return config, nil
5353+}
5454+5555+// buildLogConfig creates logging configuration from environment variables
5656+func buildLogConfig() configuration.Log {
5757+ level := getEnvOrDefault("ATCR_LOG_LEVEL", "info")
5858+ formatter := getEnvOrDefault("ATCR_LOG_FORMATTER", "text")
5959+6060+ return configuration.Log{
6161+ Level: configuration.Loglevel(level),
6262+ Formatter: formatter,
6363+ Fields: map[string]interface{}{
6464+ "service": "atcr-appview",
6565+ },
6666+ }
6767+}
6868+6969+// buildHTTPConfig creates HTTP server configuration from environment variables
7070+func buildHTTPConfig() (configuration.HTTP, error) {
7171+ addr := getEnvOrDefault("ATCR_HTTP_ADDR", ":5000")
7272+ debugAddr := getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001")
7373+7474+ return configuration.HTTP{
7575+ Addr: addr,
7676+ Headers: map[string][]string{
7777+ "X-Content-Type-Options": {"nosniff"},
7878+ },
7979+ Debug: configuration.Debug{
8080+ Addr: debugAddr,
8181+ },
8282+ }, nil
8383+}
8484+8585+// buildStorageConfig creates a fake in-memory storage config
8686+// This is required for distribution validation but is never actually used
8787+// All storage is routed through middleware to ATProto (manifests) and hold services (blobs)
8888+func buildStorageConfig() configuration.Storage {
8989+ storage := configuration.Storage{}
9090+9191+ // Use in-memory storage as a placeholder
9292+ storage["inmemory"] = configuration.Parameters{}
9393+9494+ // Disable upload purging
9595+ // NOTE: Must use map[interface{}]interface{} for uploadpurging (not configuration.Parameters)
9696+ // because distribution's validation code does a type assertion to map[interface{}]interface{}
9797+ storage["maintenance"] = configuration.Parameters{
9898+ "uploadpurging": map[interface{}]interface{}{
9999+ "enabled": false,
100100+ "age": 7 * 24 * time.Hour, // 168h
101101+ "interval": 24 * time.Hour, // 24h
102102+ "dryrun": false,
103103+ },
104104+ }
105105+106106+ return storage
107107+}
108108+109109+// buildMiddlewareConfig creates middleware configuration
110110+func buildMiddlewareConfig(defaultHold string) map[string][]configuration.Middleware {
111111+ return map[string][]configuration.Middleware{
112112+ "registry": {
113113+ {
114114+ Name: "atproto-resolver",
115115+ Options: configuration.Parameters{
116116+ "default_storage_endpoint": defaultHold,
117117+ },
118118+ },
119119+ },
120120+ }
121121+}
122122+123123+// buildAuthConfig creates authentication configuration from environment variables
124124+func buildAuthConfig(baseURL string) (configuration.Auth, error) {
125125+ // Token configuration
126126+ privateKeyPath := getEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem")
127127+ certPath := getEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt")
128128+129129+ // Token expiration in seconds (default: 5 minutes)
130130+ expirationStr := getEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300")
131131+ expiration, err := strconv.Atoi(expirationStr)
132132+ if err != nil {
133133+ return configuration.Auth{}, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err)
134134+ }
135135+136136+ // Auto-derive service name from base URL or use env var
137137+ serviceName := getServiceName(baseURL)
138138+139139+ // Auto-derive realm from base URL
140140+ realm := baseURL + "/auth/token"
141141+142142+ return configuration.Auth{
143143+ "token": configuration.Parameters{
144144+ "realm": realm,
145145+ "service": serviceName,
146146+ "issuer": serviceName,
147147+ "rootcertbundle": certPath,
148148+ "privatekey": privateKeyPath,
149149+ "expiration": expiration,
150150+ },
151151+ }, nil
152152+}
153153+154154+// buildHealthConfig creates health check configuration
155155+func buildHealthConfig() configuration.Health {
156156+ return configuration.Health{
157157+ StorageDriver: configuration.StorageDriver{
158158+ Enabled: true,
159159+ Interval: 10 * time.Second,
160160+ Threshold: 3,
161161+ },
162162+ }
163163+}
164164+165165+// getBaseURL determines the base URL for the service
166166+// Priority: ATCR_BASE_URL env var, then derived from HTTP addr
167167+func getBaseURL(httpAddr string) string {
168168+ baseURL := os.Getenv("ATCR_BASE_URL")
169169+ if baseURL != "" {
170170+ return baseURL
171171+ }
172172+173173+ // Auto-detect from HTTP addr
174174+ if httpAddr[0] == ':' {
175175+ // Just a port, assume localhost
176176+ return fmt.Sprintf("http://127.0.0.1%s", httpAddr)
177177+ }
178178+179179+ // Full address provided
180180+ return fmt.Sprintf("http://%s", httpAddr)
181181+}
182182+183183+// getServiceName extracts service name from base URL or uses env var
184184+func getServiceName(baseURL string) string {
185185+ // Check env var first
186186+ if serviceName := os.Getenv("ATCR_SERVICE_NAME"); serviceName != "" {
187187+ return serviceName
188188+ }
189189+190190+ // Try to extract from base URL
191191+ parsed, err := url.Parse(baseURL)
192192+ if err == nil && parsed.Hostname() != "" {
193193+ hostname := parsed.Hostname()
194194+195195+ // Strip localhost/127.0.0.1 and use default
196196+ if hostname == "localhost" || hostname == "127.0.0.1" {
197197+ return "atcr.io"
198198+ }
199199+200200+ return hostname
201201+ }
202202+203203+ // Default fallback
204204+ return "atcr.io"
205205+}
206206+207207+// getEnvOrDefault gets an environment variable or returns a default value
208208+func getEnvOrDefault(key, defaultValue string) string {
209209+ if val := os.Getenv(key); val != "" {
210210+ return val
211211+ }
212212+ return defaultValue
213213+}
+12-15
cmd/appview/serve.go
···6060}
61616262var serveCmd = &cobra.Command{
6363- Use: "serve <config>",
6363+ Use: "serve",
6464 Short: "Start the ATCR registry server",
6565- Long: "Start the ATCR registry server with authentication endpoints",
6666- Args: cobra.ExactArgs(1),
6767- RunE: serveRegistry,
6565+ Long: `Start the ATCR registry server with authentication endpoints.
6666+6767+Configuration is loaded from environment variables.
6868+See .env.appview.example for available environment variables.`,
6969+ Args: cobra.NoArgs,
7070+ RunE: serveRegistry,
6871}
69727073func init() {
···8790}
88918992func serveRegistry(cmd *cobra.Command, args []string) error {
9090- configPath := args[0]
9191-9292- // Parse configuration
9393- fp, err := os.Open(configPath)
9494- if err != nil {
9595- return fmt.Errorf("failed to open config file: %w", err)
9696- }
9797- defer fp.Close()
9898-9999- config, err := configuration.Parse(fp)
9393+ // Load configuration from environment variables
9494+ fmt.Println("Loading configuration from environment variables...")
9595+ config, err := loadConfigFromEnv()
10096 if err != nil {
101101- return fmt.Errorf("failed to parse configuration: %w", err)
9797+ return fmt.Errorf("failed to load config from environment: %w", err)
10298 }
9999+ fmt.Println("Configuration loaded successfully from environment")
103100104101 // Initialize UI database first (required for all stores)
105102 fmt.Println("Initializing UI database...")
-57
config/config.yml
···11-version: 0.1
22-log:
33- level: info
44- formatter: text
55- fields:
66- service: atcr-appview
77-88-# Storage is handled by external services:
99-# - Manifests/Tags -> ATProto PDS (user's personal data server)
1010-# - Blobs/Layers -> Hold service (default or BYOS)
1111-# The AppView should be stateless with no local storage
1212-#
1313-# NOTE: The storage section below is required for distribution config validation
1414-# but is NOT actually used - all blob operations are routed through hold service
1515-storage:
1616- inmemory: {}
1717-1818-http:
1919- addr: :5000
2020- headers:
2121- X-Content-Type-Options: [nosniff]
2222- debug:
2323- addr: :5001
2424-2525-middleware:
2626- registry:
2727- # Name resolution middleware
2828- - name: atproto-resolver
2929- options:
3030- # Default hold service for blob storage
3131- # Users without their own hold will use this endpoint
3232- default_storage_endpoint: http://atcr-hold:8080
3333-3434-# Authentication - all endpoints on port 5000
3535-auth:
3636- token:
3737- # Token service realm (where Docker gets tokens)
3838- realm: http://127.0.0.1:5000/auth/token
3939- service: atcr.io
4040- issuer: atcr.io
4141- expiration: 1800 # 30 minutes (in seconds)
4242-4343- # Certificate bundle for validating JWTs
4444- rootcertbundle: /var/lib/atcr/auth/private-key.crt
4545-4646- # Private key for signing JWTs (used by custom auth handlers)
4747- privatekey: /var/lib/atcr/auth/private-key.pem
4848-4949- # Token expiration in seconds (5 minutes)
5050- expiration: 300
5151-5252-# Health check
5353-health:
5454- storagedriver:
5555- enabled: true
5656- interval: 10s
5757- threshold: 3
+18-9
docker-compose.yml
···22 atcr-appview:
33 build:
44 context: .
55- dockerfile: Dockerfile
55+ dockerfile: Dockerfile.appview
66 image: atcr-appview:latest
77 container_name: atcr-appview
88 ports:
99 - "5000:5000"
1010+ # Optional: Load from .env.appview file (create from .env.appview.example)
1111+ # env_file:
1212+ # - .env.appview
1013 environment:
1111- - ATCR_UI_ENABLED=true
1212- - ATCR_BACKFILL_ENABLED=true
1414+ # Server configuration
1515+ ATCR_HTTP_ADDR: :5000
1616+ ATCR_DEFAULT_HOLD: http://atcr-hold:8080
1717+ # UI configuration
1818+ ATCR_UI_ENABLED: true
1919+ ATCR_BACKFILL_ENABLED: true
2020+ # Logging
2121+ ATCR_LOG_LEVEL: info
1322 volumes:
1423 # Auth keys (JWT signing keys)
1524 - atcr-auth:/var/lib/atcr/auth
1616- # UI database (includes OAuth sessions, devices, and firehose cache)
2525+ # UI database (includes OAuth sessions, devices, and Jetstream cache)
1726 - atcr-ui:/var/lib/atcr
1827 restart: unless-stopped
1928 dns:
···2231 networks:
2332 atcr-network:
2433 ipv4_address: 172.28.0.2
2525- # The AppView should be stateless - all storage is external:
2626- # - Manifests/Tags -> ATProto PDS
2727- # - Blobs/Layers -> Hold service
2828- # - OAuth tokens -> Persistent volume (atcr-tokens)
2929- # Future: Add read_only: true for production deployments
3434+ # The AppView is stateless - all storage is external:
3535+ # - Manifests/Tags -> ATProto PDS (via middleware)
3636+ # - Blobs/Layers -> Hold service (via ProxyBlobStore)
3737+ # - OAuth tokens -> SQLite database (atcr-ui volume)
3838+ # - No config.yml needed - all config via environment variables
30393140 atcr-hold:
3241 env_file:
+22-4
docs/BYOS.md
···4714713. **In-memory cache** - Hold endpoint cache is in-memory (for production, use Redis)
4724724. **Manual profile updates** - No UI for updating sailor profile (must use ATProto client)
473473474474+## Performance Optimization: S3 Presigned URLs
475475+476476+**Status:** Planned implementation (see [PRESIGNED_URLS.md](./PRESIGNED_URLS.md))
477477+478478+Currently, hold services act as proxies for blob data. With presigned URLs:
479479+480480+- **Downloads:** Docker → S3 direct (via 307 redirect)
481481+- **Uploads:** Docker → AppView → S3 (via presigned URL)
482482+- **Hold service bandwidth:** Reduced by 99.98% (only orchestration)
483483+484484+**Benefits:**
485485+- Hold services can run on minimal infrastructure ($5/month instances)
486486+- Direct S3 transfers at maximum speed
487487+- Scales to arbitrarily large images
488488+- Works with Storj, MinIO, Backblaze B2, Cloudflare R2
489489+490490+See [PRESIGNED_URLS.md](./PRESIGNED_URLS.md) for complete technical details and implementation guide.
491491+474492## Future Improvements
475493476476-1. **Automatic failover** - Multiple storage endpoints, fallback to default
477477-2. **Storage analytics** - Track usage per DID
478478-3. **Quota integration** - Optional quota tracking in storage service
479479-4. **Direct presigned URL support** - S3 native presigned URLs (bypass proxy)
494494+1. **S3 Presigned URLs** - Implement direct S3 URLs (see [PRESIGNED_URLS.md](./PRESIGNED_URLS.md))
495495+2. **Automatic failover** - Multiple storage endpoints, fallback to default
496496+3. **Storage analytics** - Track usage per DID
497497+4. **Quota integration** - Optional quota tracking in storage service
4804985. **Profile management UI** - Web interface for users to manage their sailor profile
4814996. **Distributed cache** - Redis/Memcached for hold endpoint cache in multi-instance deployments
482500
+637
docs/PRESIGNED_URLS.md
···11+# S3 Presigned URLs Implementation
22+33+## Overview
44+55+Currently, ATCR's hold service acts as a proxy for all blob data, meaning every byte flows through the hold service when uploading or downloading container images. This document describes the implementation of **S3 presigned URLs** to eliminate this bottleneck, allowing direct data transfer between clients and S3-compatible storage.
66+77+### Current Architecture (Proxy Mode)
88+99+```
1010+Downloads: Docker → AppView → Hold Service → S3 → Hold Service → AppView → Docker
1111+Uploads: Docker → AppView → Hold Service → S3
1212+```
1313+1414+**Problems:**
1515+- All blob data flows through hold service
1616+- Hold service bandwidth = total image bandwidth
1717+- Latency from extra hops
1818+- Hold service becomes bottleneck for large images
1919+2020+### Target Architecture (Presigned URLs)
2121+2222+```
2323+Downloads: Docker → AppView (gets presigned URL) → S3 (direct download)
2424+Uploads: Docker → AppView → S3 (via presigned URL)
2525+Move: AppView → Hold Service → S3 (server-side CopyObject API)
2626+```
2727+2828+**Benefits:**
2929+- ✅ Hold service only orchestrates (no data transfer)
3030+- ✅ Blob data never touches hold service
3131+- ✅ Direct S3 uploads/downloads at wire speed
3232+- ✅ Hold service can run on minimal resources
3333+- ✅ Works with all S3-compatible services
3434+3535+## How Presigned URLs Work
3636+3737+### For Downloads (GET)
3838+3939+1. **Docker requests blob:** `GET /v2/alice/myapp/blobs/sha256:abc123`
4040+2. **AppView asks hold service:** `POST /get-presigned-url`
4141+ ```json
4242+ {"did": "did:plc:alice123", "digest": "sha256:abc123"}
4343+ ```
4444+3. **Hold service generates presigned URL:**
4545+ ```go
4646+ req, _ := s3Client.GetObjectRequest(&s3.GetObjectInput{
4747+ Bucket: "my-bucket",
4848+ Key: "blobs/sha256/ab/abc123.../data",
4949+ })
5050+ url, _ := req.Presign(15 * time.Minute)
5151+ // Returns: https://gateway.storjshare.io/bucket/blobs/...?X-Amz-Signature=...
5252+ ```
5353+4. **AppView redirects Docker:** `HTTP 307 Location: <presigned-url>`
5454+5. **Docker downloads directly from S3** using the presigned URL
5555+5656+**Data path:** Docker → S3 (direct)
5757+**Hold service bandwidth:** ~1KB (API request/response)
5858+5959+### For Uploads (PUT)
6060+6161+**Small blobs (< 5MB) using Put():**
6262+6363+1. **Docker sends blob to AppView:** `PUT /v2/alice/myapp/blobs/uploads/{uuid}`
6464+2. **AppView asks hold service:** `POST /put-presigned-url`
6565+ ```json
6666+ {"did": "did:plc:alice123", "digest": "sha256:abc123", "size": 1024}
6767+ ```
6868+3. **Hold service generates presigned URL:**
6969+ ```go
7070+ req, _ := s3Client.PutObjectRequest(&s3.PutObjectInput{
7171+ Bucket: "my-bucket",
7272+ Key: "blobs/sha256/ab/abc123.../data",
7373+ })
7474+ url, _ := req.Presign(15 * time.Minute)
7575+ ```
7676+4. **AppView uploads to S3** using presigned URL
7777+5. **AppView confirms to Docker:** `201 Created`
7878+7979+**Data path:** Docker → AppView → S3 (via presigned URL)
8080+**Hold service bandwidth:** ~1KB (API request/response)
8181+8282+### For Streaming Uploads (Create/Commit)
8383+8484+**Large blobs (> 5MB) using streaming:**
8585+8686+1. **Docker starts upload:** `POST /v2/alice/myapp/blobs/uploads/`
8787+2. **AppView creates upload session** with UUID
8888+3. **AppView gets presigned URL for temp location:**
8989+ ```json
9090+ POST /put-presigned-url
9191+ {"did": "...", "digest": "uploads/temp-{uuid}", "size": 0}
9292+ ```
9393+4. **Docker streams data:** `PATCH /v2/alice/myapp/blobs/uploads/{uuid}`
9494+5. **AppView streams to S3** using presigned URL to `uploads/temp-{uuid}/data`
9595+6. **Docker finalizes:** `PUT /v2/.../uploads/{uuid}?digest=sha256:abc123`
9696+7. **AppView requests move:** `POST /move?from=uploads/temp-{uuid}&to=sha256:abc123`
9797+8. **Hold service executes S3 server-side copy:**
9898+ ```go
9999+ s3.CopyObject(&s3.CopyObjectInput{
100100+ Bucket: "my-bucket",
101101+ CopySource: "/my-bucket/uploads/temp-{uuid}/data",
102102+ Key: "blobs/sha256/ab/abc123.../data",
103103+ })
104104+ s3.DeleteObject(&s3.DeleteObjectInput{
105105+ Key: "uploads/temp-{uuid}/data",
106106+ })
107107+ ```
108108+109109+**Data path:** Docker → AppView → S3 (temp location)
110110+**Move path:** S3 internal copy (no data transfer!)
111111+**Hold service bandwidth:** ~2KB (presigned URL + CopyObject API)
112112+113113+## Why the Temp → Final Move is Required
114114+115115+This is **not an ATCR implementation detail** — it's required by the [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push).
116116+117117+### The Problem: Unknown Digest
118118+119119+Docker doesn't know the blob's digest until **after** uploading:
120120+121121+1. **Streaming data:** Can't buffer 5GB layer in memory to calculate digest first
122122+2. **Stdin pipes:** `docker build . | docker push` generates data on-the-fly
123123+3. **Chunked uploads:** Multiple PATCH requests, digest calculated as data streams
124124+125125+### The Solution: Upload to Temp, Verify, Move
126126+127127+**All OCI registries do this:**
128128+129129+1. Client: `POST /v2/{name}/blobs/uploads/` → Get upload UUID
130130+2. Client: `PATCH /v2/{name}/blobs/uploads/{uuid}` → Stream data to temp location
131131+3. Client: `PUT /v2/{name}/blobs/uploads/{uuid}?digest=sha256:abc` → Provide digest
132132+4. Registry: Verify digest matches uploaded data
133133+5. Registry: Move `uploads/{uuid}` → `blobs/sha256/abc123...`
134134+135135+**Docker Hub, GHCR, ECR, Harbor — all use this pattern.**
136136+137137+### Why It's Efficient with S3
138138+139139+**For S3, the move is a CopyObject API call:**
140140+141141+```go
142142+// This happens INSIDE S3 servers - no data transfer!
143143+s3.CopyObject(&s3.CopyObjectInput{
144144+ Bucket: "my-bucket",
145145+ CopySource: "/my-bucket/uploads/temp-12345/data", // 5GB blob
146146+ Key: "blobs/sha256/ab/abc123.../data",
147147+})
148148+// S3 copies internally, hold service only sends ~1KB API request
149149+```
150150+151151+**For a 5GB layer:**
152152+- Hold service bandwidth: **~1KB** (API request/response)
153153+- S3 internal copy: Instant (metadata operation on S3 side)
154154+- No data leaves S3, no network transfer
155155+156156+This is why the move operation is essentially free!
157157+158158+## Implementation Details
159159+160160+### 1. Add S3 Client to Hold Service
161161+162162+**File: `cmd/hold/main.go`**
163163+164164+Modify `HoldService` struct:
165165+```go
166166+type HoldService struct {
167167+ driver storagedriver.StorageDriver
168168+ config *Config
169169+ s3Client *s3.S3 // NEW: S3 client for presigned URLs
170170+ bucket string // NEW: Bucket name
171171+ s3PathPrefix string // NEW: Path prefix (if any)
172172+}
173173+```
174174+175175+Add initialization function:
176176+```go
177177+func (s *HoldService) initS3Client() error {
178178+ if s.config.Storage.Type() != "s3" {
179179+ log.Printf("Storage driver is %s (not S3), presigned URLs disabled", s.config.Storage.Type())
180180+ return nil
181181+ }
182182+183183+ params := s.config.Storage.Parameters()["s3"].(configuration.Parameters)
184184+185185+ // Build AWS config
186186+ awsConfig := &aws.Config{
187187+ Region: aws.String(params["region"].(string)),
188188+ Credentials: credentials.NewStaticCredentials(
189189+ params["accesskey"].(string),
190190+ params["secretkey"].(string),
191191+ "",
192192+ ),
193193+ }
194194+195195+ // Add custom endpoint for S3-compatible services (Storj, MinIO, etc.)
196196+ if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" {
197197+ awsConfig.Endpoint = aws.String(endpoint)
198198+ awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj
199199+ }
200200+201201+ sess, err := session.NewSession(awsConfig)
202202+ if err != nil {
203203+ return fmt.Errorf("failed to create AWS session: %w", err)
204204+ }
205205+206206+ s.s3Client = s3.New(sess)
207207+ s.bucket = params["bucket"].(string)
208208+209209+ log.Printf("S3 presigned URLs enabled for bucket: %s", s.bucket)
210210+ return nil
211211+}
212212+```
213213+214214+Call during service initialization:
215215+```go
216216+func NewHoldService(cfg *Config) (*HoldService, error) {
217217+ // ... existing driver creation ...
218218+219219+ service := &HoldService{
220220+ driver: driver,
221221+ config: cfg,
222222+ }
223223+224224+ // Initialize S3 client for presigned URLs
225225+ if err := service.initS3Client(); err != nil {
226226+ log.Printf("WARNING: S3 presigned URLs disabled: %v", err)
227227+ }
228228+229229+ return service, nil
230230+}
231231+```
232232+233233+### 2. Implement Presigned URL Generation
234234+235235+**For Downloads:**
236236+237237+```go
238238+func (s *HoldService) getDownloadURL(ctx context.Context, digest string, did string) (string, error) {
239239+ path := blobPath(digest)
240240+241241+ // Check if blob exists
242242+ if _, err := s.driver.Stat(ctx, path); err != nil {
243243+ return "", fmt.Errorf("blob not found: %w", err)
244244+ }
245245+246246+ // If S3 client available, generate presigned URL
247247+ if s.s3Client != nil {
248248+ s3Key := strings.TrimPrefix(path, "/")
249249+250250+ req, _ := s.s3Client.GetObjectRequest(&s3.GetObjectInput{
251251+ Bucket: aws.String(s.bucket),
252252+ Key: aws.String(s3Key),
253253+ })
254254+255255+ url, err := req.Presign(15 * time.Minute)
256256+ if err != nil {
257257+ log.Printf("WARN: Presigned URL generation failed, falling back to proxy: %v", err)
258258+ return s.getProxyDownloadURL(digest, did), nil
259259+ }
260260+261261+ log.Printf("Generated presigned download URL for %s (expires in 15min)", digest)
262262+ return url, nil
263263+ }
264264+265265+ // Fallback: return proxy URL
266266+ return s.getProxyDownloadURL(digest, did), nil
267267+}
268268+269269+func (s *HoldService) getProxyDownloadURL(digest, did string) string {
270270+ return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did)
271271+}
272272+```
273273+274274+**For Uploads:**
275275+276276+```go
277277+func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64, did string) (string, error) {
278278+ path := blobPath(digest)
279279+280280+ // If S3 client available, generate presigned URL
281281+ if s.s3Client != nil {
282282+ s3Key := strings.TrimPrefix(path, "/")
283283+284284+ req, _ := s.s3Client.PutObjectRequest(&s3.PutObjectInput{
285285+ Bucket: aws.String(s.bucket),
286286+ Key: aws.String(s3Key),
287287+ })
288288+289289+ url, err := req.Presign(15 * time.Minute)
290290+ if err != nil {
291291+ log.Printf("WARN: Presigned URL generation failed, falling back to proxy: %v", err)
292292+ return s.getProxyUploadURL(digest, did), nil
293293+ }
294294+295295+ log.Printf("Generated presigned upload URL for %s (expires in 15min)", digest)
296296+ return url, nil
297297+ }
298298+299299+ // Fallback: return proxy URL
300300+ return s.getProxyUploadURL(digest, did), nil
301301+}
302302+303303+func (s *HoldService) getProxyUploadURL(digest, did string) string {
304304+ return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did)
305305+}
306306+```
307307+308308+### 3. No Changes Needed for Move Operation
309309+310310+The existing `/move` endpoint already uses `driver.Move()`, which for S3:
311311+- Calls `s3.CopyObject()` (server-side copy)
312312+- Calls `s3.DeleteObject()` (delete source)
313313+- No data transfer through hold service!
314314+315315+**File: `cmd/hold/main.go:296` (already exists, no changes needed)**
316316+317317+```go
318318+func (s *HoldService) HandleMove(w http.ResponseWriter, r *http.Request) {
319319+ // ... existing auth and parsing ...
320320+321321+ sourcePath := blobPath(fromPath) // uploads/temp-{uuid}/data
322322+ destPath := blobPath(toDigest) // blobs/sha256/ab/abc123.../data
323323+324324+ // For S3, this does CopyObject + DeleteObject (server-side)
325325+ if err := s.driver.Move(ctx, sourcePath, destPath); err != nil {
326326+ // ... error handling ...
327327+ }
328328+}
329329+```
330330+331331+### 4. AppView Changes (Optional Optimization)
332332+333333+**File: `pkg/storage/proxy_blob_store.go:228`**
334334+335335+Currently streams to hold service proxy URL. Could be optimized to use presigned URL:
336336+337337+```go
338338+// In Create() - line 228
339339+go func() {
340340+ defer pipeReader.Close()
341341+342342+ tempPath := fmt.Sprintf("uploads/temp-%s", writer.id)
343343+344344+ // Try to get presigned URL for temp location
345345+ url, err := p.getUploadURL(ctx, digest.FromString(tempPath), 0)
346346+ if err != nil {
347347+ // Fallback to direct proxy URL
348348+ url = fmt.Sprintf("%s/blobs/%s?did=%s", p.storageEndpoint, tempPath, p.did)
349349+ }
350350+351351+ req, err := http.NewRequestWithContext(uploadCtx, "PUT", url, pipeReader)
352352+ // ... rest unchanged
353353+}()
354354+```
355355+356356+**Note:** This optimization is optional. The presigned URL will be returned by hold service's `getUploadURL()` anyway.
357357+358358+## S3-Compatible Service Support
359359+360360+### Storj
361361+362362+```bash
363363+# .env file
364364+STORAGE_DRIVER=s3
365365+AWS_ACCESS_KEY_ID=your-storj-access-key
366366+AWS_SECRET_ACCESS_KEY=your-storj-secret-key
367367+S3_BUCKET=your-bucket-name
368368+S3_REGION=global
369369+S3_ENDPOINT=https://gateway.storjshare.io
370370+```
371371+372372+**Presigned URL example:**
373373+```
374374+https://gateway.storjshare.io/your-bucket/blobs/sha256/ab/abc123.../data?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...
375375+```
376376+377377+### MinIO
378378+379379+```bash
380380+STORAGE_DRIVER=s3
381381+AWS_ACCESS_KEY_ID=minioadmin
382382+AWS_SECRET_ACCESS_KEY=minioadmin
383383+S3_BUCKET=registry
384384+S3_REGION=us-east-1
385385+S3_ENDPOINT=http://minio.example.com:9000
386386+```
387387+388388+### Backblaze B2
389389+390390+```bash
391391+STORAGE_DRIVER=s3
392392+AWS_ACCESS_KEY_ID=your-b2-key-id
393393+AWS_SECRET_ACCESS_KEY=your-b2-application-key
394394+S3_BUCKET=your-bucket-name
395395+S3_REGION=us-west-002
396396+S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
397397+```
398398+399399+### Cloudflare R2
400400+401401+```bash
402402+STORAGE_DRIVER=s3
403403+AWS_ACCESS_KEY_ID=your-r2-access-key-id
404404+AWS_SECRET_ACCESS_KEY=your-r2-secret-access-key
405405+S3_BUCKET=your-bucket-name
406406+S3_REGION=auto
407407+S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
408408+```
409409+410410+**All these services support presigned URLs with AWS SDK v1!**
411411+412412+## Performance Impact
413413+414414+### Bandwidth Savings
415415+416416+**Before (proxy mode):**
417417+- 5GB layer upload: Hold service receives 5GB, sends 5GB to S3 = **10GB** bandwidth
418418+- 5GB layer download: S3 sends 5GB to hold, hold sends 5GB to client = **10GB** bandwidth
419419+- **Total for push+pull: 20GB hold service bandwidth**
420420+421421+**After (presigned URLs):**
422422+- 5GB layer upload: Hold generates URL (1KB), AppView → S3 direct (5GB), CopyObject API (1KB) = **~2KB** hold bandwidth
423423+- 5GB layer download: Hold generates URL (1KB), client → S3 direct = **~1KB** hold bandwidth
424424+- **Total for push+pull: ~3KB hold service bandwidth**
425425+426426+**Savings: 99.98% reduction in hold service bandwidth!**
427427+428428+### Latency Improvements
429429+430430+**Before:**
431431+- Download: Client → AppView → Hold → S3 → Hold → AppView → Client (4 hops)
432432+- Upload: Client → AppView → Hold → S3 (3 hops)
433433+434434+**After:**
435435+- Download: Client → AppView (redirect) → S3 (1 hop to data)
436436+- Upload: Client → AppView → S3 (2 hops)
437437+- Move: S3 internal (no network hops)
438438+439439+### Resource Requirements
440440+441441+**Before:**
442442+- Hold service needs bandwidth = sum of all image operations
443443+- For 100 concurrent 1GB pushes: 100GB/s bandwidth needed
444444+- Expensive, hard to scale
445445+446446+**After:**
447447+- Hold service needs minimal CPU for presigned URL signing
448448+- For 100 concurrent 1GB pushes: ~100KB/s bandwidth needed (API traffic)
449449+- Can run on $5/month instance!
450450+451451+## Security Considerations
452452+453453+### Presigned URL Expiration
454454+455455+- Default: **15 minutes** expiration
456456+- Presigned URL includes embedded credentials in query params
457457+- After expiry, URL becomes invalid (S3 rejects with 403)
458458+- No long-lived URLs floating around
459459+460460+### Authorization Flow
461461+462462+1. **AppView validates user** via ATProto OAuth
463463+2. **AppView passes DID to hold service** in presigned URL request
464464+3. **Hold service validates DID** (owner or crew member)
465465+4. **Hold service generates presigned URL** if authorized
466466+5. **Client uses presigned URL** directly with S3
467467+468468+**Security boundary:** Hold service controls who gets presigned URLs, S3 validates the URLs.
469469+470470+### Fallback Security
471471+472472+If presigned URL generation fails:
473473+- Falls back to proxy URLs (existing behavior)
474474+- Still requires hold service authorization
475475+- Data flows through hold service (original security model)
476476+477477+## Testing & Validation
478478+479479+### Verify Presigned URLs are Used
480480+481481+**1. Check hold service logs:**
482482+```bash
483483+docker logs atcr-hold | grep -i presigned
484484+# Should see: "Generated presigned download/upload URL for sha256:..."
485485+```
486486+487487+**2. Monitor network traffic:**
488488+```bash
489489+# Before: Large data transfers to/from hold service
490490+docker stats atcr-hold
491491+492492+# After: Minimal network usage on hold service
493493+docker stats atcr-hold
494494+```
495495+496496+**3. Inspect redirect responses:**
497497+```bash
498498+# Should see 307 redirect to S3 URL
499499+curl -v http://appview:5000/v2/alice/myapp/blobs/sha256:abc123 \
500500+ -H "Authorization: Bearer $TOKEN"
501501+502502+# Look for:
503503+# < HTTP/1.1 307 Temporary Redirect
504504+# < Location: https://gateway.storjshare.io/...?X-Amz-Signature=...
505505+```
506506+507507+### Test Fallback Behavior
508508+509509+**1. With filesystem driver (should use proxy URLs):**
510510+```bash
511511+STORAGE_DRIVER=filesystem docker-compose up atcr-hold
512512+# Logs should show: "Storage driver is filesystem (not S3), presigned URLs disabled"
513513+```
514514+515515+**2. With S3 but invalid credentials (should fall back):**
516516+```bash
517517+AWS_ACCESS_KEY_ID=invalid docker-compose up atcr-hold
518518+# Logs should show: "WARN: Presigned URL generation failed, falling back to proxy"
519519+```
520520+521521+### Bandwidth Monitoring
522522+523523+**Track hold service bandwidth over time:**
524524+```bash
525525+# Install bandwidth monitoring
526526+docker exec atcr-hold apt-get update && apt-get install -y vnstat
527527+528528+# Monitor
529529+docker exec atcr-hold vnstat -l
530530+```
531531+532532+**Expected results:**
533533+- Before: Bandwidth correlates with image operations
534534+- After: Bandwidth stays minimal regardless of image operations
535535+536536+## Migration Guide
537537+538538+### For Existing ATCR Deployments
539539+540540+**1. Update hold service code** (this implementation)
541541+542542+**2. No configuration changes needed** if already using S3:
543543+```bash
544544+# Existing S3 config works automatically
545545+STORAGE_DRIVER=s3
546546+AWS_ACCESS_KEY_ID=...
547547+AWS_SECRET_ACCESS_KEY=...
548548+S3_BUCKET=...
549549+S3_ENDPOINT=...
550550+```
551551+552552+**3. Restart hold service:**
553553+```bash
554554+docker-compose restart atcr-hold
555555+```
556556+557557+**4. Verify in logs:**
558558+```
559559+S3 presigned URLs enabled for bucket: my-bucket
560560+```
561561+562562+**5. Test with image push/pull:**
563563+```bash
564564+docker push atcr.io/alice/myapp:latest
565565+docker pull atcr.io/alice/myapp:latest
566566+```
567567+568568+**6. Monitor bandwidth** to confirm reduction
569569+570570+### Rollback Plan
571571+572572+If issues arise:
573573+574574+**Option 1: Disable presigned URLs via env var** (if we add this feature)
575575+```bash
576576+PRESIGNED_URLS_ENABLED=false docker-compose restart atcr-hold
577577+```
578578+579579+**Option 2: Revert code changes** to previous hold service version
580580+581581+The implementation has automatic fallbacks, so partial failures won't break functionality.
582582+583583+## Future Enhancements
584584+585585+### 1. Configurable Expiration
586586+587587+Allow customizing presigned URL expiry:
588588+```bash
589589+PRESIGNED_URL_EXPIRY=30m # Default: 15m
590590+```
591591+592592+### 2. Presigned URL Caching
593593+594594+Cache presigned URLs for frequently accessed blobs (with shorter TTL).
595595+596596+### 3. CloudFront/CDN Integration
597597+598598+For downloads, use CloudFront presigned URLs instead of direct S3:
599599+- Better global distribution
600600+- Lower egress costs
601601+- Faster downloads
602602+603603+### 4. Multipart Upload Support
604604+605605+For very large layers (>5GB), use presigned URLs with multipart upload:
606606+- Generate presigned URLs for each part
607607+- Client uploads parts directly to S3
608608+- Hold service finalizes multipart upload
609609+610610+### 5. Metrics & Monitoring
611611+612612+Track presigned URL usage:
613613+- Count of presigned URLs generated
614614+- Fallback rate (proxy vs presigned)
615615+- Bandwidth savings metrics
616616+617617+## References
618618+619619+- [OCI Distribution Specification - Push](https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push)
620620+- [AWS SDK Go v1 - Presigned URLs](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/s3-example-presigned-urls.html)
621621+- [Storj - Using Presigned URLs](https://docs.storj.io/dcs/api-reference/s3-compatible-gateway/using-presigned-urls)
622622+- [MinIO - Presigned Upload via Browser](https://docs.min.io/community/minio-object-store/integrations/presigned-put-upload-via-browser.html)
623623+- [Cloudflare R2 - Presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)
624624+- [Backblaze B2 - S3 Compatible API](https://help.backblaze.com/hc/en-us/articles/360047815993-Does-the-B2-S3-Compatible-API-support-Pre-Signed-URLs)
625625+626626+## Summary
627627+628628+Implementing S3 presigned URLs transforms ATCR's hold service from a **data proxy** to a **lightweight orchestrator**:
629629+630630+✅ **99.98% bandwidth reduction** for hold service
631631+✅ **Direct client → S3 transfers** for maximum speed
632632+✅ **Works with all S3-compatible services** (Storj, MinIO, R2, B2)
633633+✅ **OCI-compliant** temp → final move pattern
634634+✅ **Automatic fallback** to proxy mode for non-S3 drivers
635635+✅ **No breaking changes** to existing deployments
636636+637637+This makes BYOS (Bring Your Own Storage) truly scalable and cost-effective, as users can run hold services on minimal infrastructure while serving arbitrarily large container images.