# Docker Deployment Guide This guide covers Docker deployment for ActaLog, including the single-port production architecture, multi-stage build process, and deployment options. ## Table of Contents - [Architecture Overview](#architecture-overview) - [Quick Start](#quick-start) - [Building Images](#building-images) - [Image Labels (OCI Metadata)](#image-labels-oci-metadata) - [Deployment Options](#deployment-options) - [Configuration](#configuration) - [Volumes and Data Persistence](#volumes-and-data-persistence) - [Database Options](#database-options) - [Troubleshooting](#troubleshooting) ## Architecture Overview ### Single-Port Production Architecture ActaLog uses a **production-optimized single-port architecture** where the Go backend serves both the API and the frontend static files from port 8080. **Key Benefits:** - Simpler deployment (only one port to expose) - No CORS configuration needed - Lower resource usage (no Node.js in production) - Industry-standard pattern for production SPAs - Easier reverse proxy configuration **How It Works:** ``` Port 8080 → Go Application ├── /api/* → Backend API endpoints ├── /uploads/* → User-uploaded files └── /* → Frontend static files (with SPA routing) ``` **Route Priority:** 1. Health check: `/health` → API health status 2. API routes: `/api/*` → Backend handlers 3. Uploads: `/uploads/*` → Static file server 4. Frontend: `/*` → Serves static files or `index.html` for SPA routing **Implementation:** See `cmd/actalog/main.go:251-262` for frontend directory configuration and lines 418-436 for static file serving logic. ### Multi-Stage Build Process The Dockerfile uses a multi-stage build to create an optimized production image: ```dockerfile # Stage 1: Build Frontend (Node.js) FROM node:20-alpine AS frontend-builder # ... builds /app/web/dist # Stage 2: Build Backend (Go) FROM golang:1.23-alpine AS backend-builder # ... builds /app/bin/actalog # Stage 3: Runtime (Alpine) FROM alpine:latest # Copies both frontend and backend ``` **Why Multi-Stage?** - Smaller final image (~50MB vs 1GB+) - No build tools in production image - Security: minimal attack surface - Separate build caching for frontend and backend ## Quick Start ### Pull and Run (Simplest) ```bash # Pull the latest image from GitHub Container Registry docker pull ghcr.io/johnzastrow/actalog:latest # Run with SQLite (single-container deployment) docker run -d \ -p 8080:8080 \ -v actalog-data:/app/data \ -v actalog-uploads:/app/uploads \ --name actalog \ ghcr.io/johnzastrow/actalog:latest # Access the application open http://localhost:8080 ``` ### Using Docker Compose (Recommended for Production) ```bash # SQLite (development/single-user) docker-compose -f docker/docker-compose.sqlite.yml up -d # PostgreSQL (production recommended) docker-compose -f docker/docker-compose.postgresql.yml up -d # MariaDB (alternative production database) docker-compose -f docker/docker-compose.mariadb.yml up -d ``` ## Building Images ### Using Build Script (Recommended) ```bash # Build and tag image (can run from anywhere in the repo) ./docker/scripts/build.sh v0.10.0 # Or from within the scripts directory cd docker/scripts ./build.sh v0.10.0 # Push to GitHub Container Registry (automatically uses gh CLI auth if available) ./docker/scripts/push.sh v0.10.0 ``` **Note:** Both scripts automatically detect the repository root and change to it, so you can run them from any directory within the repository. **What the build script does:** 1. Checks for uncommitted changes (fails if dirty) 2. Runs multi-stage Docker build 3. Tags with version and 'latest' 4. Displays instructions for pushing **What the push script does:** 1. Checks if Docker is already logged in to ghcr.io 2. **Automatically authenticates using `gh` CLI if available** (recommended) 3. Falls back to manual login if `gh` is not installed/authenticated 4. Pushes tagged images to GitHub Container Registry **Automatic Authentication (Recommended):** If you have the GitHub CLI (`gh`) installed and authenticated, the push script will automatically use your GitHub token: ```bash # One-time setup (if not already authenticated) gh auth login # Now push script will use gh token automatically ./docker/scripts/push.sh v0.10.0 ``` **Manual Authentication (Alternative):** If `gh` CLI is not available, the script will prompt you to log in manually: ```bash # Using Personal Access Token (PAT) docker login ghcr.io # Or using environment variables echo $GITHUB_TOKEN | docker login ghcr.io -u $GITHUB_USERNAME --password-stdin ``` **Required GitHub Token Scopes:** - `write:packages` - Push images to GHCR - `read:packages` - Pull images from GHCR - `delete:packages` - Delete image versions (optional) ### Manual Build ```bash # Build from root of repository docker build -t actalog:local -f docker/Dockerfile . # Run locally built image docker run -p 8080:8080 -v actalog-data:/app/data actalog:local ``` ### Build Arguments The Dockerfile supports build-time configuration: ```bash docker build \ --build-arg GOTOOLCHAIN=auto \ -t actalog:custom \ -f docker/Dockerfile . ``` ## Image Labels (OCI Metadata) The build script automatically adds [OCI Image Format Specification](https://github.com/opencontainers/image-spec/blob/main/annotations.md) labels to Docker images. These labels provide metadata about the image that can be viewed by users and registries like GitHub Container Registry. ### What Are OCI Labels? OCI (Open Container Initiative) labels are standardized metadata embedded in Docker images. They help identify: - What the image contains and does - Who created it and when - Where to find documentation and source code - Version and licensing information **GitHub Container Registry Integration:** The `org.opencontainers.image.description` label is automatically displayed on your package page in GHCR, below the package name. ### User-Editable Labels The build script (`docker/scripts/build.sh`) includes a clearly marked section at the top for customizing image metadata: ```bash # ============================================================================ # USER-EDITABLE LABELS - Customize these for your deployment # ============================================================================ # Description: A short text description of the image (max 512 characters) # This will appear on the GitHub package page below the package name IMAGE_DESCRIPTION="ActaLog - A mobile-first CrossFit workout tracker..." # Vendor/Organization name IMAGE_VENDOR="John Zastrow" # License identifier (SPDX format recommended) IMAGE_LICENSE="MIT" # Authors (comma-separated if multiple) IMAGE_AUTHORS="John Zastrow" # Documentation URL (optional) IMAGE_DOCUMENTATION="" ``` **To customize:** Edit these variables in `docker/scripts/build.sh` before building your image. ### Dynamic Labels (Auto-Generated) The following labels are automatically extracted at build time: | Label | Source | Example Value | |-------|--------|---------------| | `org.opencontainers.image.version` | `pkg/version/version.go` | `0.11.0-beta+build.71` | | `org.opencontainers.image.created` | Build timestamp | `2025-11-26T14:30:00Z` | | `org.opencontainers.image.revision` | Git commit hash | `61c7b43...` (full SHA) | | `org.opencontainers.image.ref.name` | Git branch | `main` | | `org.opencontainers.image.source` | Repository URL | `https://github.com/johnzastrow/actalog` | | `build.number` | `pkg/version/version.go` | `71` | | `build.git.commit` | Git + dirty check | `61c7b43` or `61c7b43-dirty` | | `build.git.branch` | Git branch | `main` | ### Complete Label Reference | OCI Standard Label | Description | |-------------------|-------------| | `org.opencontainers.image.title` | Image name ("ActaLog") | | `org.opencontainers.image.description` | Short description (max 512 chars) - shown on GHCR | | `org.opencontainers.image.version` | Semantic version with build number | | `org.opencontainers.image.created` | RFC 3339 timestamp of build | | `org.opencontainers.image.revision` | Full git commit SHA | | `org.opencontainers.image.source` | URL to source code repository | | `org.opencontainers.image.url` | URL to project homepage | | `org.opencontainers.image.vendor` | Organization/person name | | `org.opencontainers.image.licenses` | SPDX license identifier | | `org.opencontainers.image.authors` | Contact information for authors | | `org.opencontainers.image.ref.name` | Git reference (branch/tag) | | `org.opencontainers.image.base.name` | Base image used (`alpine:latest`) | ### Viewing Image Labels **View all labels (formatted with jq):** ```bash docker inspect ghcr.io/johnzastrow/actalog:latest \ --format '{{json .Config.Labels}}' | jq ``` **View a specific label:** ```bash docker inspect ghcr.io/johnzastrow/actalog:latest \ --format '{{index .Config.Labels "org.opencontainers.image.version"}}' ``` **Example output:** ```json { "org.opencontainers.image.title": "ActaLog", "org.opencontainers.image.description": "ActaLog - A mobile-first CrossFit workout tracker...", "org.opencontainers.image.version": "0.11.0-beta+build.71", "org.opencontainers.image.created": "2025-11-26T14:30:00Z", "org.opencontainers.image.revision": "61c7b43abc123def456...", "org.opencontainers.image.source": "https://github.com/johnzastrow/actalog", "org.opencontainers.image.vendor": "John Zastrow", "org.opencontainers.image.licenses": "MIT", "org.opencontainers.image.authors": "John Zastrow", "org.opencontainers.image.ref.name": "main", "org.opencontainers.image.base.name": "alpine:latest", "build.number": "71", "build.git.commit": "61c7b43", "build.git.branch": "main" } ``` ### Build Script Output When you run the build script, it displays the metadata being applied: ``` ============================================ ActaLog Docker Build Script ============================================ Repository: /home/user/actalog Version: 0.11.0-beta (Build 71) Registry: ghcr.io Image: johnzastrow/actalog Tag: latest Platform: linux/amd64 Build Metadata: Git Commit: 61c7b43 Git Branch: main Build Date: 2025-11-26T14:30:00Z Image Labels (edit in script header): Description: ActaLog - A mobile-first CrossFit workout tracker... Vendor: John Zastrow License: MIT Building Docker image... ``` ### GitHub Container Registry Display After pushing to GHCR, the `org.opencontainers.image.description` label appears: 1. Go to: `https://github.com/USERNAME/REPO/pkgs/container/actalog` 2. The description appears below the package name 3. Version and other metadata may be shown depending on GHCR features **Note:** It may take a few minutes for GHCR to process and display the label after pushing. ### Custom Labels You can add additional custom labels by modifying the `docker buildx build` command in `build.sh`: ```bash docker buildx build \ # ... existing labels ... --label "com.example.custom-label=value" \ --label "com.example.environment=production" \ # ... rest of command ... ``` **Naming convention:** Custom labels should use a reverse domain name prefix (e.g., `com.yourcompany.labelname`). ## Deployment Options ### Option 1: Single Container with SQLite **Best for:** Development, testing, single-user deployments ```bash docker run -d \ -p 8080:8080 \ -e DB_DRIVER=sqlite3 \ -e DB_NAME=/app/data/actalog.db \ -v actalog-data:/app/data \ -v actalog-uploads:/app/uploads \ --name actalog \ --restart unless-stopped \ ghcr.io/johnzastrow/actalog:latest ``` **Advantages:** - Simplest deployment (one container) - No external database needed - Easy backups (copy volume) **Limitations:** - No horizontal scaling - Single point of failure ### Option 2: Multi-Container with PostgreSQL **Best for:** Production, multi-user, high-availability ```bash # Using docker-compose docker-compose -f docker/docker-compose.postgresql.yml up -d ``` **docker-compose.postgresql.yml:** ```yaml version: '3.8' services: postgres: image: postgres:16-alpine environment: POSTGRES_USER: actalog POSTGRES_PASSWORD: changeme POSTGRES_DB: actalog volumes: - postgres-data:/var/lib/postgresql/data restart: unless-stopped actalog: image: ghcr.io/johnzastrow/actalog:latest ports: - "8080:8080" environment: DB_DRIVER: postgres DB_HOST: postgres DB_PORT: 5432 DB_USER: actalog DB_PASSWORD: changeme DB_NAME: actalog DB_SSLMODE: disable volumes: - actalog-uploads:/app/uploads depends_on: - postgres restart: unless-stopped volumes: postgres-data: actalog-uploads: ``` **Advantages:** - Production-ready database - Better performance at scale - Proper transaction support - Can scale horizontally ### Option 3: Behind Reverse Proxy (Nginx/Caddy) **Best for:** Production with TLS, multiple services ```nginx # /etc/nginx/sites-available/actalog server { listen 80; server_name actalog.example.com; # Redirect HTTP to HTTPS return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name actalog.example.com; ssl_certificate /etc/letsencrypt/live/actalog.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/actalog.example.com/privkey.pem; location / { proxy_pass http://localhost:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; # Increase timeouts for large uploads client_max_body_size 50M; proxy_read_timeout 300s; proxy_connect_timeout 75s; } } ``` **Caddy (simpler alternative):** ```caddyfile actalog.example.com { reverse_proxy localhost:8080 } ``` ## Configuration ### Environment Variables All configuration is done via environment variables: **Database:** ```bash DB_DRIVER=sqlite3 # sqlite3, postgres, mysql DB_NAME=/app/data/actalog.db # For SQLite DB_HOST=postgres # For PostgreSQL/MySQL DB_PORT=5432 # Database port DB_USER=actalog # Database user DB_PASSWORD=changeme # Database password DB_NAME=actalog # Database name DB_SSLMODE=disable # PostgreSQL SSL mode DB_SCHEMA=public # PostgreSQL schema ``` **Application:** ```bash PORT=8080 # Application port APP_URL=https://actalog.example.com # Base URL for emails CORS_ORIGINS=https://actalog.example.com # CORS allowed origins ALLOW_REGISTRATION=true # Allow new user registration ``` **Authentication:** ```bash JWT_SECRET=changeme-in-production # JWT signing secret (REQUIRED) JWT_EXPIRATION=24h # Access token lifetime JWT_REFRESH_DURATION=168h # Refresh token lifetime (7 days) ``` **Email (Optional):** ```bash EMAIL_ENABLED=true EMAIL_FROM=noreply@actalog.example.com EMAIL_SMTP_HOST=smtp.gmail.com EMAIL_SMTP_PORT=587 EMAIL_SMTP_USER=your-email@gmail.com EMAIL_SMTP_PASSWORD=your-app-password EMAIL_REQUIRE_VERIFICATION=false ``` **Security:** ```bash MAX_LOGIN_ATTEMPTS=5 # Max failed login attempts ACCOUNT_LOCKOUT_DURATION=30m # Account lock duration ``` **Frontend (Build-time):** ```bash FRONTEND_DIR=/app/web/dist # Frontend static files location ``` ### Docker Environment File Create `.env` file for docker-compose: ```bash # .env DB_DRIVER=postgres DB_HOST=postgres DB_PORT=5432 DB_USER=actalog DB_PASSWORD=your-secure-password-here DB_NAME=actalog JWT_SECRET=your-jwt-secret-here-min-32-chars EMAIL_ENABLED=true EMAIL_FROM=noreply@actalog.example.com EMAIL_SMTP_HOST=smtp.gmail.com EMAIL_SMTP_PORT=587 EMAIL_SMTP_USER=your-email@gmail.com EMAIL_SMTP_PASSWORD=your-app-password APP_URL=https://actalog.example.com CORS_ORIGINS=https://actalog.example.com ``` Then reference in docker-compose.yml: ```yaml services: actalog: env_file: .env ``` ## Volumes and Data Persistence ### Required Volumes 1. **`/app/data`** - Database files (for SQLite) and application data ```bash -v actalog-data:/app/data ``` 2. **`/app/uploads`** - User-uploaded files (avatars, etc.) ```bash -v actalog-uploads:/app/uploads ``` ### Backup Strategy #### SQLite Backup ```bash # Create backup docker exec actalog sqlite3 /app/data/actalog.db ".backup '/app/data/backup.db'" # Copy backup to host docker cp actalog:/app/data/backup.db ./actalog-backup-$(date +%Y%m%d).db # Or use volume mount docker run --rm \ -v actalog-data:/data \ -v $(pwd)/backups:/backups \ alpine cp /data/actalog.db /backups/actalog-$(date +%Y%m%d).db ``` #### PostgreSQL Backup ```bash # Dump database docker exec postgres pg_dump -U actalog actalog > backup.sql # Restore database docker exec -i postgres psql -U actalog actalog < backup.sql ``` #### Uploads Backup ```bash # Tar and compress uploads docker run --rm \ -v actalog-uploads:/uploads \ -v $(pwd)/backups:/backups \ alpine tar czf /backups/uploads-$(date +%Y%m%d).tar.gz -C /uploads . ``` ## Database Options ### SQLite (Default) **Configuration:** ```bash -e DB_DRIVER=sqlite3 -e DB_NAME=/app/data/actalog.db ``` **Pros:** - Zero configuration - Fast for single-user - Easy backups **Cons:** - No concurrent writes - Limited scalability ### PostgreSQL (Recommended for Production) **Configuration:** ```bash -e DB_DRIVER=postgres -e DB_HOST=postgres -e DB_PORT=5432 -e DB_USER=actalog -e DB_PASSWORD=changeme -e DB_NAME=actalog -e DB_SSLMODE=disable -e DB_SCHEMA=public ``` **Pros:** - Production-grade - Horizontal scaling - ACID compliance - Advanced features ### MariaDB/MySQL **Configuration:** ```bash -e DB_DRIVER=mysql -e DB_HOST=mariadb -e DB_PORT=3306 -e DB_USER=actalog -e DB_PASSWORD=changeme -e DB_NAME=actalog ``` **Pros:** - Widely supported - Good performance - Familiar to many admins ## Troubleshooting ### Container Won't Start **Check logs:** ```bash docker logs actalog ``` **Common issues:** - Missing JWT_SECRET environment variable - Database connection failure - Port 8080 already in use ### Frontend Not Serving **Verify frontend files exist:** ```bash docker exec actalog ls -la /app/web/dist ``` **Check logs for:** ``` Serving frontend from: /app/web/dist ``` **If missing:** - Image might not have been built correctly - Rebuild with: `./docker/scripts/build.sh` ### Database Connection Errors **SQLite:** ```bash # Check if database file exists docker exec actalog ls -la /app/data/ # Check permissions docker exec actalog ls -la /app/data/actalog.db ``` **PostgreSQL/MariaDB:** ```bash # Test database connection docker exec actalog nc -zv postgres 5432 # Check database logs docker logs postgres ``` ### 404 Errors for Frontend Routes This is expected behavior! The Go backend serves `index.html` for all non-existent paths to support Vue Router's history mode. **If still getting 404s:** 1. Check that `/` returns the frontend 2. Verify frontend files exist: `docker exec actalog ls /app/web/dist` 3. Check logs for static file serving configuration ### Permission Denied Errors The container runs as non-root user `actalog` (UID 1000): ```bash # If you need to fix permissions on volumes docker run --rm -v actalog-data:/data alpine chown -R 1000:1000 /data docker run --rm -v actalog-uploads:/uploads alpine chown -R 1000:1000 /uploads ``` ### High Memory Usage **Check resource usage:** ```bash docker stats actalog ``` **Limit resources:** ```bash docker run -d \ --memory="512m" \ --cpus="1.0" \ -p 8080:8080 \ ghcr.io/johnzastrow/actalog:latest ``` ### Seed Data Not Loading The application automatically imports seed data (movements and WODs) on first run. **Check if seeds were imported:** ```bash docker exec actalog ls -la /app/data/.seeds_imported ``` **If marker file doesn't exist:** ```bash # Manually run seed import docker exec actalog /app/scripts/init-seeds.sh ``` ## Advanced Topics ### Custom Seed Data Mount your own seed files: ```bash docker run -d \ -p 8080:8080 \ -v ./custom-movements.csv:/app/seeds/movements.csv:ro \ -v ./custom-wods.csv:/app/seeds/wods.csv:ro \ ghcr.io/johnzastrow/actalog:latest ``` ### Health Checks The image includes a built-in health check: ```dockerfile HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 ``` **Check container health:** ```bash docker inspect --format='{{.State.Health.Status}}' actalog ``` ### Multi-Instance Deployment For horizontal scaling: 1. Use shared database (PostgreSQL/MariaDB) 2. Use shared uploads storage (NFS/S3) 3. Run multiple containers behind load balancer ```bash # Instance 1 docker run -d --name actalog-1 -p 8081:8080 \ -e DB_DRIVER=postgres -e DB_HOST=postgres-server \ -v shared-uploads:/app/uploads \ ghcr.io/johnzastrow/actalog:latest # Instance 2 docker run -d --name actalog-2 -p 8082:8080 \ -e DB_DRIVER=postgres -e DB_HOST=postgres-server \ -v shared-uploads:/app/uploads \ ghcr.io/johnzastrow/actalog:latest # Load balancer forwards to both instances ``` ## Security Best Practices 1. **Change default JWT secret:** ```bash JWT_SECRET=$(openssl rand -base64 32) ``` 2. **Use environment files for secrets:** - Never commit `.env` files - Use Docker secrets or external secret management 3. **Enable HTTPS:** - Use reverse proxy (Nginx/Caddy) with TLS - Required for PWA features 4. **Restrict CORS:** ```bash CORS_ORIGINS=https://yourdomain.com ``` 5. **Regular updates:** ```bash docker pull ghcr.io/johnzastrow/actalog:latest docker-compose down && docker-compose up -d ``` 6. **Network isolation:** ```yaml services: postgres: networks: - backend actalog: networks: - backend - frontend ``` ## Production Checklist - [ ] PostgreSQL or MariaDB configured (not SQLite) - [ ] Custom JWT_SECRET set (min 32 characters) - [ ] CORS_ORIGINS configured for your domain - [ ] TLS/HTTPS enabled via reverse proxy - [ ] Volumes configured for data persistence - [ ] Backup strategy implemented - [ ] Health checks enabled - [ ] Resource limits set - [ ] Logging configured - [ ] Monitoring in place ## Support For issues or questions: - GitHub Issues: https://github.com/johnzastrow/actalog/issues - Documentation: See `docs/` directory - Architecture: See `docs/ARCHITECTURE.md` - Development: See `CLAUDE.md`