Docker
This guide explains how to build and run the mjr.wtf URL shortener using Docker.
Quick Start with Pre-built Images
Section titled “Quick Start with Pre-built Images”The easiest way to get started is using the pre-built multi-arch images from GitHub Container Registry:
# Pull the latest versiondocker pull ghcr.io/matt-riley/mjrwtf:latest
# Run with environment filedocker run -d \ --name mjrwtf \ -p 8080:8080 \ --env-file .env \ ghcr.io/matt-riley/mjrwtf:latestAvailable image tags:
latest- Latest published release1.2.3- Specific semantic version (from the release tagmjrwtf-v1.2.3)1.2- Latest patch version in the 1.2.x series1- Latest minor and patch version in the 1.x series
All images support both linux/amd64 and linux/arm64 architectures.
Overview
Section titled “Overview”The project includes a multi-stage Dockerfile that:
- Uses
golang:1.25-alpinefor building (with CGO support for SQLite) - Uses
alpine:latestfor the runtime image - Creates a minimal, secure container (~20-30MB without GeoIP database)
- Runs as a non-root user for security
- Includes health checks
Building the Docker Image
Section titled “Building the Docker Image”Using Pre-built Images (Recommended)
Section titled “Using Pre-built Images (Recommended)”Production-ready images are automatically built and published to GitHub Container Registry on each release:
# Pull a specific versiondocker pull ghcr.io/matt-riley/mjrwtf:1.0.0
# Pull the latest versiondocker pull ghcr.io/matt-riley/mjrwtf:latest
# Tag for local usedocker tag ghcr.io/matt-riley/mjrwtf:latest mjrwtf:latestBuilding Locally
Section titled “Building Locally”# Build the imagedocker build -t mjrwtf:latest .
# Build with a specific tagdocker build -t mjrwtf:1.0.0 .The build process:
- Installs build dependencies (gcc, musl-dev for CGO)
- Downloads Go dependencies
- Generates Templ templates
- Compiles the server binary with CGO enabled
- Creates a minimal runtime image with only the binary
Running the Container
Section titled “Running the Container”Basic Usage
Section titled “Basic Usage”docker run -d \ --name mjrwtf \ -p 8080:8080 \ -v ./data:/app/data \ -e DATABASE_URL=/app/data/database.db \ -e AUTH_TOKENS=your-secret-token \ mjrwtf:latestWith Environment File
Section titled “With Environment File”Create a .env file (copy from .env.example):
docker run -d \ --name mjrwtf \ -p 8080:8080 \ --env-file .env \ mjrwtf:latestWith SQLite and Persistent Storage
Section titled “With SQLite and Persistent Storage”docker run -d \ --name mjrwtf \ -p 8080:8080 \ -v ./data:/app/data \ -e DATABASE_URL=/app/data/database.db \ -e AUTH_TOKENS=your-secret-token \ mjrwtf:latestWith GeoIP Database
Section titled “With GeoIP Database”docker run -d \ --name mjrwtf \ -p 8080:8080 \ -v ./data:/app/data \ -v ./GeoLite2-Country.mmdb:/app/geoip.mmdb:ro \ -e DATABASE_URL=/app/data/database.db \ -e AUTH_TOKENS=your-secret-token \ -e GEOIP_ENABLED=true \ -e GEOIP_DATABASE=/app/geoip.mmdb \ mjrwtf:latestDocker Compose
Section titled “Docker Compose”The project includes a docker-compose.yml file that runs the server with a persistent SQLite database in a bind-mounted ./data directory.
Quick Start
Section titled “Quick Start”# 1. Copy environment variablescp .env.example .env# Edit .env to set AUTH_TOKENS (preferred) or AUTH_TOKEN (legacy)
# 2. Prepare a persistent data directorymkdir -p data
# 3. Start the server (Docker Compose runs migrations automatically on startup via docker-entrypoint.sh)make docker-compose-up
# 4. Verify the application is readycurl http://localhost:8080/healthcurl http://localhost:8080/ready
# View logsmake docker-compose-logs
# Stop the servicemake docker-compose-downNote: The container uses /app/data/database.db; the bind mount maps that to ./data/database.db on the host.
Troubleshooting
Section titled “Troubleshooting”Services won’t start:
# Check service statusdocker compose ps
# View logsdocker compose logs
# Rebuild containersdocker compose up -d --buildPort already in use:
# Check what's using port 8080lsof -i :8080
# Use a different port (edit docker-compose.yml)# Change "8080:8080" to "9090:8080"Environment Variables
Section titled “Environment Variables”See .env.example for all available environment variables:
DATABASE_URL- SQLite database file path (required)AUTH_TOKENS- API authentication tokens (recommended; comma-separated; takes precedence)AUTH_TOKEN- API authentication token (legacy; used only if AUTH_TOKENS is unset)SERVER_PORT- HTTP server port (default: 8080)LOG_LEVEL- Log level: debug, info, warn, error (default: info)LOG_FORMAT- Log format: json, pretty (default: json)ALLOWED_ORIGINS- CORS allowed origins (default: *)GEOIP_ENABLED- Enable GeoIP tracking (default: false)GEOIP_DATABASE- Path to GeoIP database (required if GEOIP_ENABLED=true)
Health Checks
Section titled “Health Checks”The container includes a health check that queries the /health endpoint (liveness). For orchestrator-style readiness probes, use /ready.
# Check container healthdocker ps
# View health check logsdocker inspect --format='{{json .State.Health}}' mjrwtf | jqThe health check configuration:
- Interval: 30 seconds
- Timeout: 3 seconds
- Start Period: 5 seconds
- Retries: 3
Image Size
Section titled “Image Size”Expected image sizes:
- Without GeoIP database: ~20-30MB
- With GeoIP database: ~30-80MB (depending on database size)
Check image size:
docker images mjrwtfSecurity
Section titled “Security”The Docker image follows security best practices:
- Non-root User: Runs as user
appuser(UID 1000) - Minimal Base: Uses Alpine Linux for small attack surface
- No Secrets: Secrets are passed via environment variables, not baked in
- Read-only GeoIP: GeoIP database mounted read-only when used
- Health Checks: Monitors application health
Troubleshooting
Section titled “Troubleshooting”Container Won’t Start
Section titled “Container Won’t Start”# Check logsdocker logs mjrwtf
# Common issues:# - Missing AUTH_TOKENS/AUTH_TOKEN environment variable# - Invalid DATABASE_URL# - Database connection failedHealth Check Failing
Section titled “Health Check Failing”# Check if the service is respondingdocker exec mjrwtf curl -f http://localhost:8080/health
# Check application logsdocker logs mjrwtfPort Already in Use
Section titled “Port Already in Use”# Use a different portdocker run -p 9090:8080 mjrwtf:latestDatabase Migrations
Section titled “Database Migrations”The image runs migrations automatically on startup via docker-entrypoint.sh (it executes ./migrate up before starting the server).
Ensure DATABASE_URL points to a writable SQLite file (for example /app/data/database.db with -v ./data:/app/data).
If you prefer to run migrations explicitly (e.g., in CI/deploy pipelines), you can run the embedded migrate binary:
docker run --rm \ -v ./data:/app/data \ -e DATABASE_URL=/app/data/database.db \ mjrwtf:latest ./migrate upDevelopment
Section titled “Development”For local development, prefer running the server directly on your host (e.g. make build-server && ./bin/server) rather than bind-mounting over /app (which would hide the container binaries).
Production Deployment
Section titled “Production Deployment”For production:
- Use specific image tags (not
latest) - Set strong
AUTH_TOKENS(recommended) orAUTH_TOKEN - Configure proper CORS (
ALLOWED_ORIGINS) - Use
LOG_FORMAT=jsonfor structured logging - Mount a volume for persistent SQLite data
- Use secrets management (Docker secrets, Kubernetes secrets, etc.)
- Run behind a reverse proxy (nginx, Traefik, etc.)
Example production run:
docker run -d \ --name mjrwtf \ --restart unless-stopped \ -p 127.0.0.1:8080:8080 \ -v /srv/mjrwtf:/app/data \ -e DATABASE_URL=/app/data/database.db \ -e AUTH_TOKENS=$(cat /run/secrets/auth_token) \ -e LOG_LEVEL=info \ -e LOG_FORMAT=json \ -e ALLOWED_ORIGINS=https://mjr.wtf \ mjrwtf:v1.0.0