A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

Development Workflow for ATCR#

The Problem#

Current development cycle with Docker:

  1. Edit CSS, JS, template, or Go file
  2. Run docker compose build (rebuilds entire image)
  3. Run docker compose up (restart container)
  4. Wait 2-3 minutes for changes to appear
  5. Test, find issue, repeat...

Why it's slow:

  • All assets embedded via embed.FS at compile time
  • Multi-stage Docker build compiles everything from scratch
  • No development mode exists
  • Final image uses scratch base (no tools, no hot reload)

The Solution#

Development setup combining:

  1. Dockerfile.devel - Development-focused container (golang base, not scratch)
  2. Volume mounts - Live code editing (changes appear instantly in container)
  3. DirFS - Skip embed, read templates/CSS/JS from filesystem
  4. Air - Auto-rebuild on Go code changes

Results:

  • CSS/JS/Template changes: Instant (0 seconds, just refresh browser)
  • Go code changes: 2-5 seconds (vs 2-3 minutes)
  • Production builds: Unchanged (still optimized with embed.FS)

How It Works#

Architecture Flow#

┌─────────────────────────────────────────────────────┐
│ Your Editor (VSCode, etc)                           │
│ Edit: style.css, app.js, *.html, *.go files        │
└─────────────────┬───────────────────────────────────┘
                  │ (files saved to disk)
                  ▼
┌─────────────────────────────────────────────────────┐
│ Volume Mount (docker-compose.dev.yml)               │
│   volumes:                                          │
│     - .:/app  (entire codebase mounted)            │
└─────────────────┬───────────────────────────────────┘
                  │ (changes appear instantly in container)
                  ▼
┌─────────────────────────────────────────────────────┐
│ Container (golang:1.25.2 base, has all tools)      │
│                                                     │
│  ┌──────────────────────────────────────┐          │
│  │ Air (hot reload tool)                │          │
│  │ Watches: *.go, *.html, *.css, *.js  │          │
│  │                                      │          │
│  │ On change:                           │          │
│  │   - *.go → rebuild binary (2-5s)    │          │
│  │   - templates/css/js → restart only  │          │
│  └──────────────────────────────────────┘          │
│                 │                                   │
│                 ▼                                   │
│  ┌──────────────────────────────────────┐          │
│  │ ATCR AppView (ATCR_DEV_MODE=true)    │          │
│  │                                      │          │
│  │ ui.go checks DEV_MODE:               │          │
│  │   if DEV_MODE:                       │          │
│  │     templatesFS = os.DirFS("...")    │          │
│  │     staticFS = os.DirFS("...")       │          │
│  │   else:                              │          │
│  │     use embed.FS (production)        │          │
│  │                                      │          │
│  │ Result: Reads from mounted files     │          │
│  └──────────────────────────────────────┘          │
└─────────────────────────────────────────────────────┘

Change Scenarios#

Scenario 1: Edit CSS/JS/Templates#

1. Edit pkg/appview/static/css/style.css in VSCode
2. Save file
3. Change appears in container via volume mount (instant)
4. App uses os.DirFS → reads new file from disk (instant)
5. Refresh browser → see changes

Time: Instant (0 seconds) No rebuild, no restart!

Scenario 2: Edit Go Code#

1. Edit pkg/appview/handlers/home.go
2. Save file
3. Air detects .go file change
4. Air runs: go build -o ./tmp/atcr-appview ./cmd/appview
5. Air kills old process and starts new binary
6. App runs with new code

Time: 2-5 seconds Fast incremental build!

Implementation#

Step 1: Create Dockerfile.devel#

Create Dockerfile.devel in project root:

# Development Dockerfile with hot reload support
FROM golang:1.25.2-trixie

# Install Air for hot reload
RUN go install github.com/cosmtrek/air@latest

# Install SQLite (required for CGO in ATCR)
RUN apt-get update && apt-get install -y \
    sqlite3 \
    libsqlite3-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy dependency files and download (cached layer)
COPY go.mod go.sum ./
RUN go mod download

# Note: Source code comes from volume mount
# (no COPY . . needed - that's the whole point!)

# Air will handle building and running
CMD ["air", "-c", ".air.toml"]

Step 2: Create docker-compose.dev.yml#

Create docker-compose.dev.yml in project root:

version: '3.8'

services:
  atcr-appview:
    build:
      context: .
      dockerfile: Dockerfile.devel
    volumes:
      # Mount entire codebase (live editing)
      - .:/app
      # Cache Go modules (faster rebuilds)
      - go-cache:/go/pkg/mod
      # Persist SQLite database
      - atcr-ui-dev:/var/lib/atcr
    environment:
      # Enable development mode (uses os.DirFS)
      ATCR_DEV_MODE: "true"

      # AppView configuration
      ATCR_HTTP_ADDR: ":5000"
      ATCR_BASE_URL: "http://localhost:5000"
      ATCR_DEFAULT_HOLD_DID: "did:web:hold01.atcr.io"

      # Database
      ATCR_UI_DATABASE_PATH: "/var/lib/atcr/ui.db"

      # Auth
      ATCR_AUTH_KEY_PATH: "/var/lib/atcr/auth/private-key.pem"

      # UI
      ATCR_UI_ENABLED: "true"

      # Jetstream (optional)
      # JETSTREAM_URL: "wss://jetstream2.us-east.bsky.network/subscribe"
      # ATCR_BACKFILL_ENABLED: "false"
    ports:
      - "5000:5000"
    networks:
      - atcr-dev

  # Add other services as needed (postgres, hold, etc)
  # atcr-hold:
  #   ...

networks:
  atcr-dev:
    driver: bridge

volumes:
  go-cache:
  atcr-ui-dev:

Step 3: Create .air.toml#

Create .air.toml in project root:

# Air configuration for hot reload
# https://github.com/cosmtrek/air

root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  # Arguments to pass to binary (AppView needs "serve")
  args_bin = ["serve"]

  # Where to output the built binary
  bin = "./tmp/atcr-appview"

  # Build command
  cmd = "go build -o ./tmp/atcr-appview ./cmd/appview"

  # Delay before rebuilding (ms) - debounce rapid saves
  delay = 1000

  # Directories to exclude from watching
  exclude_dir = [
    "tmp",
    "vendor",
    "bin",
    ".git",
    "node_modules",
    "testdata"
  ]

  # Files to exclude from watching
  exclude_file = []

  # Regex patterns to exclude
  exclude_regex = ["_test\\.go"]

  # Don't rebuild if file content unchanged
  exclude_unchanged = false

  # Follow symlinks
  follow_symlink = false

  # Full command to run (leave empty to use cmd + bin)
  full_bin = ""

  # Directories to include (empty = all)
  include_dir = []

  # File extensions to watch
  include_ext = ["go", "html", "css", "js"]

  # Specific files to watch
  include_file = []

  # Delay before killing old process (s)
  kill_delay = "0s"

  # Log file for build errors
  log = "build-errors.log"

  # Use polling instead of fsnotify (for Docker/VM)
  poll = false
  poll_interval = 0

  # Rerun binary if it exits
  rerun = false
  rerun_delay = 500

  # Send interrupt signal instead of kill
  send_interrupt = false

  # Stop on build error
  stop_on_error = false

[color]
  # Colorize output
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  # Show only app logs (not build logs)
  main_only = false

  # Add timestamp to logs
  time = false

[misc]
  # Clean tmp directory on exit
  clean_on_exit = false

[screen]
  # Clear screen on rebuild
  clear_on_rebuild = false

  # Keep scrollback
  keep_scroll = true

Step 4: Modify pkg/appview/ui.go#

Add conditional filesystem loading to pkg/appview/ui.go:

package appview

import (
	"embed"
	"html/template"
	"io/fs"
	"log"
	"net/http"
	"os"
)

// Embedded assets (used in production)
//go:embed templates/**/*.html
var embeddedTemplatesFS embed.FS

//go:embed static
var embeddedStaticFS embed.FS

// Actual filesystems used at runtime (conditional)
var templatesFS fs.FS
var staticFS fs.FS

func init() {
	// Development mode: read from filesystem for instant updates
	if os.Getenv("ATCR_DEV_MODE") == "true" {
		log.Println("🔧 DEV MODE: Using filesystem for templates and static assets")
		templatesFS = os.DirFS("pkg/appview/templates")
		staticFS = os.DirFS("pkg/appview/static")
	} else {
		// Production mode: use embedded assets
		log.Println("📦 PRODUCTION MODE: Using embedded assets")
		templatesFS = embeddedTemplatesFS
		staticFS = embeddedStaticFS
	}
}

// Templates returns parsed HTML templates
func Templates() *template.Template {
	tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html")
	if err != nil {
		log.Fatalf("Failed to parse templates: %v", err)
	}
	return tmpl
}

// StaticHandler returns a handler for static files
func StaticHandler() http.Handler {
	sub, err := fs.Sub(staticFS, "static")
	if err != nil {
		log.Fatalf("Failed to create static sub-filesystem: %v", err)
	}
	return http.FileServer(http.FS(sub))
}

Important: Update the Templates() function to NOT cache templates in dev mode:

// Templates returns parsed HTML templates
func Templates() *template.Template {
	// In dev mode, reparse templates on every request (instant updates)
	// In production, this could be cached
	tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html")
	if err != nil {
		log.Fatalf("Failed to parse templates: %v", err)
	}
	return tmpl
}

If you're caching templates, wrap it with a dev mode check:

var templateCache *template.Template

func Templates() *template.Template {
	// Development: reparse every time (instant updates)
	if os.Getenv("ATCR_DEV_MODE") == "true" {
		tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html")
		if err != nil {
			log.Printf("Template parse error: %v", err)
			return template.New("error")
		}
		return tmpl
	}

	// Production: use cached templates
	if templateCache == nil {
		tmpl, err := template.ParseFS(templatesFS, "templates/**/*.html")
		if err != nil {
			log.Fatalf("Failed to parse templates: %v", err)
		}
		templateCache = tmpl
	}
	return templateCache
}

Step 5: Add to .gitignore#

Add Air's temporary directory to .gitignore:

# Air hot reload
tmp/
build-errors.log

Usage#

Starting Development Environment#

# Build and start dev container
docker compose -f docker-compose.dev.yml up --build

# Or run in background
docker compose -f docker-compose.dev.yml up -d

# View logs
docker compose -f docker-compose.dev.yml logs -f atcr-appview

You should see Air starting:

atcr-appview  | 🔧 DEV MODE: Using filesystem for templates and static assets
atcr-appview  |
atcr-appview  |   __    _   ___
atcr-appview  |  / /\  | | | |_)
atcr-appview  | /_/--\ |_| |_| \_ , built with Go
atcr-appview  |
atcr-appview  | watching .
atcr-appview  | !exclude tmp
atcr-appview  | building...
atcr-appview  | running...

Development Workflow#

1. Edit Templates/CSS/JS (Instant Updates)#

# Edit any template, CSS, or JS file
vim pkg/appview/templates/pages/home.html
vim pkg/appview/static/css/style.css
vim pkg/appview/static/js/app.js

# Save file → changes appear instantly
# Just refresh browser (Cmd+R / Ctrl+R)

No rebuild, no restart! Air might restart the app, but it's instant since no compilation is needed.

2. Edit Go Code (Fast Rebuild)#

# Edit any Go file
vim pkg/appview/handlers/home.go

# Save file → Air detects change
# Air output shows:
# building...
# build successful in 2.3s
# restarting...

# Refresh browser to see changes

2-5 second rebuild instead of 2-3 minutes!

Stopping Development Environment#

# Stop containers
docker compose -f docker-compose.dev.yml down

# Stop and remove volumes (fresh start)
docker compose -f docker-compose.dev.yml down -v

Production Builds#

Production builds are completely unchanged:

# Production uses normal Dockerfile (embed.FS, scratch base)
docker compose build

# Or specific service
docker compose build atcr-appview

# Run production
docker compose up

Why it works:

  • Production doesn't set ATCR_DEV_MODE=true
  • ui.go defaults to embedded assets when env var is unset
  • Production Dockerfile still uses multi-stage build to scratch
  • No development dependencies in production image

Comparison#

Change Type Before (docker compose) After (dev setup) Improvement
Edit CSS 2-3 minutes Instant (0s) ♾️x faster
Edit JS 2-3 minutes Instant (0s) ♾️x faster
Edit Template 2-3 minutes Instant (0s) ♾️x faster
Edit Go Code 2-3 minutes 2-5 seconds 24-90x faster
Production Build Same Same No change

Advanced: Local Development (No Docker)#

For even faster development, run locally without Docker:

# Set environment variables
export ATCR_DEV_MODE=true
export ATCR_HTTP_ADDR=:5000
export ATCR_BASE_URL=http://localhost:5000
export ATCR_DEFAULT_HOLD_DID=did:web:hold01.atcr.io
export ATCR_UI_DATABASE_PATH=/tmp/atcr-ui.db
export ATCR_AUTH_KEY_PATH=/tmp/atcr-auth-key.pem
export ATCR_UI_ENABLED=true

# Or use .env file
source .env.appview

# Run with Air
air -c .air.toml

# Or run directly (no hot reload)
go run ./cmd/appview serve

Advantages:

  • Even faster (no Docker overhead)
  • Native debugging with delve
  • Direct filesystem access
  • Full IDE integration

Disadvantages:

  • Need to manage dependencies locally (SQLite, etc)
  • May differ from production environment

Troubleshooting#

Air Not Rebuilding#

Problem: Air doesn't detect changes

Solution:

# Check if Air is actually running
docker compose -f docker-compose.dev.yml logs atcr-appview

# Check .air.toml include_ext includes your file type
# Default: ["go", "html", "css", "js"]

# Restart container
docker compose -f docker-compose.dev.yml restart atcr-appview

Templates Not Updating#

Problem: Template changes don't appear

Solution:

# Check ATCR_DEV_MODE is set
docker compose -f docker-compose.dev.yml exec atcr-appview env | grep DEV_MODE

# Should output: ATCR_DEV_MODE=true

# Check templates aren't cached (see Step 4 above)
# Templates() should reparse in dev mode

Go Build Failing#

Problem: Air shows build errors

Solution:

# Check build logs
docker compose -f docker-compose.dev.yml logs atcr-appview

# Or check build-errors.log in container
docker compose -f docker-compose.dev.yml exec atcr-appview cat build-errors.log

# Fix the Go error, save file, Air will retry

Volume Mount Not Working#

Problem: Changes don't appear in container

Solution:

# Verify volume mount
docker compose -f docker-compose.dev.yml exec atcr-appview ls -la /app

# Should show your source files

# On Windows/Mac, check Docker Desktop file sharing settings
# Settings → Resources → File Sharing → add project directory

Permission Errors#

Problem: Cannot write to /var/lib/atcr

Solution:

# In Dockerfile.devel, add:
RUN mkdir -p /var/lib/atcr && chmod 777 /var/lib/atcr

# Or use named volumes (already in docker-compose.dev.yml)
volumes:
  - atcr-ui-dev:/var/lib/atcr

Slow Builds Even with Air#

Problem: Air rebuilds slowly

Solution:

# Use Go module cache volume (already in docker-compose.dev.yml)
volumes:
  - go-cache:/go/pkg/mod

# Increase Air delay to debounce rapid saves
# In .air.toml:
delay = 2000  # 2 seconds

# Or check if CGO is slowing builds
# AppView needs CGO for SQLite, but you can try:
CGO_ENABLED=0 go build  # (won't work for ATCR, but good to know)

Tips & Tricks#

Browser Auto-Reload (LiveReload)#

Add LiveReload for automatic browser refresh:

# Install browser extension
# Chrome: https://chrome.google.com/webstore/detail/livereload
# Firefox: https://addons.mozilla.org/en-US/firefox/addon/livereload-web-extension/

# Add livereload to .air.toml (future Air feature)
# Or use a separate tool like browsersync

Database Resets#

Development database is in a named volume:

# Reset database (fresh start)
docker compose -f docker-compose.dev.yml down -v
docker compose -f docker-compose.dev.yml up

# Or delete specific volume
docker volume rm atcr_atcr-ui-dev

Multiple Environments#

Run dev and production side-by-side:

# Development on port 5000
docker compose -f docker-compose.dev.yml up -d

# Production on port 5001
docker compose up -d

# Now you can compare behavior

Debugging with Delve#

Add delve to Dockerfile.devel:

RUN go install github.com/go-delve/delve/cmd/dlv@latest

# Change CMD to use delve
CMD ["dlv", "debug", "./cmd/appview", "--headless", "--listen=:2345", "--api-version=2", "--accept-multiclient", "--", "serve"]

Then connect with VSCode or GoLand.

Summary#

Development Setup (One-Time):

  1. Create Dockerfile.devel
  2. Create docker-compose.dev.yml
  3. Create .air.toml
  4. Modify pkg/appview/ui.go for conditional DirFS
  5. Add tmp/ to .gitignore

Daily Development:

# Start
docker compose -f docker-compose.dev.yml up

# Edit files in your editor
# Changes appear instantly (CSS/JS/templates)
# Or in 2-5 seconds (Go code)

# Stop
docker compose -f docker-compose.dev.yml down

Production (Unchanged):

docker compose build
docker compose up

Result: 100x faster development iteration! 🚀