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

Configure Feed

Select the types of activity you want to include in your feed.

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.7 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("...")    │          │
│  │     publicFS = os.DirFS("...")       │          │
│  │   else:                              │          │
│  │     use embed.FS (production)        │          │
│  │                                      │          │
│  │ Result: Reads from mounted files     │          │
│  └──────────────────────────────────────┘          │
└─────────────────────────────────────────────────────┘

Change Scenarios#

Scenario 1: Edit CSS/JS/Templates#

1. Edit pkg/appview/public/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.7-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"

      # 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 embeddedpublicFS embed.FS

// Actual filesystems used at runtime (conditional)
var templatesFS fs.FS
var publicFS 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")
		publicFS = os.DirFS("pkg/appview/static")
	} else {
		// Production mode: use embedded assets
		log.Println("📦 PRODUCTION MODE: Using embedded assets")
		templatesFS = embeddedTemplatesFS
		publicFS = embeddedpublicFS
	}
}

// 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(publicFS, "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/public/css/style.css
vim pkg/appview/public/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

# 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! 🚀