Development Workflow for ATCR#
The Problem#
Current development cycle with Docker:
- Edit CSS, JS, template, or Go file
- Run
docker compose build(rebuilds entire image) - Run
docker compose up(restart container) - Wait 2-3 minutes for changes to appear
- Test, find issue, repeat...
Why it's slow:
- All assets embedded via
embed.FSat compile time - Multi-stage Docker build compiles everything from scratch
- No development mode exists
- Final image uses
scratchbase (no tools, no hot reload)
The Solution#
Development setup combining:
- Dockerfile.devel - Development-focused container (golang base, not scratch)
- Volume mounts - Live code editing (changes appear instantly in container)
- DirFS - Skip embed, read templates/CSS/JS from filesystem
- 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.godefaults 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):
- Create
Dockerfile.devel - Create
docker-compose.dev.yml - Create
.air.toml - Modify
pkg/appview/ui.gofor conditional DirFS - 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! 🚀