Skip to content

Docker

This guide explains how to build and run the mjr.wtf URL shortener using Docker.

The easiest way to get started is using the pre-built multi-arch images from GitHub Container Registry:

Terminal window
# Pull the latest version
docker pull ghcr.io/matt-riley/mjrwtf:latest
# Run with environment file
docker run -d \
--name mjrwtf \
-p 8080:8080 \
--env-file .env \
ghcr.io/matt-riley/mjrwtf:latest

Available image tags:

  • latest - Latest published release
  • 1.2.3 - Specific semantic version (from the release tag mjrwtf-v1.2.3)
  • 1.2 - Latest patch version in the 1.2.x series
  • 1 - Latest minor and patch version in the 1.x series

All images support both linux/amd64 and linux/arm64 architectures.

The project includes a multi-stage Dockerfile that:

  • Uses golang:1.25-alpine for building (with CGO support for SQLite)
  • Uses alpine:latest for the runtime image
  • Creates a minimal, secure container (~20-30MB without GeoIP database)
  • Runs as a non-root user for security
  • Includes health checks

Production-ready images are automatically built and published to GitHub Container Registry on each release:

Terminal window
# Pull a specific version
docker pull ghcr.io/matt-riley/mjrwtf:1.0.0
# Pull the latest version
docker pull ghcr.io/matt-riley/mjrwtf:latest
# Tag for local use
docker tag ghcr.io/matt-riley/mjrwtf:latest mjrwtf:latest
Terminal window
# Build the image
docker build -t mjrwtf:latest .
# Build with a specific tag
docker build -t mjrwtf:1.0.0 .

The build process:

  1. Installs build dependencies (gcc, musl-dev for CGO)
  2. Downloads Go dependencies
  3. Generates Templ templates
  4. Compiles the server binary with CGO enabled
  5. Creates a minimal runtime image with only the binary
Terminal window
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:latest

Create a .env file (copy from .env.example):

Terminal window
docker run -d \
--name mjrwtf \
-p 8080:8080 \
--env-file .env \
mjrwtf:latest
Terminal window
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:latest
Terminal window
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:latest

The project includes a docker-compose.yml file that runs the server with a persistent SQLite database in a bind-mounted ./data directory.

Terminal window
# 1. Copy environment variables
cp .env.example .env
# Edit .env to set AUTH_TOKENS (preferred) or AUTH_TOKEN (legacy)
# 2. Prepare a persistent data directory
mkdir -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 ready
curl http://localhost:8080/health
curl http://localhost:8080/ready
# View logs
make docker-compose-logs
# Stop the service
make docker-compose-down

Note: The container uses /app/data/database.db; the bind mount maps that to ./data/database.db on the host.

Services won’t start:

Terminal window
# Check service status
docker compose ps
# View logs
docker compose logs
# Rebuild containers
docker compose up -d --build

Port already in use:

Terminal window
# Check what's using port 8080
lsof -i :8080
# Use a different port (edit docker-compose.yml)
# Change "8080:8080" to "9090:8080"

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)

The container includes a health check that queries the /health endpoint (liveness). For orchestrator-style readiness probes, use /ready.

Terminal window
# Check container health
docker ps
# View health check logs
docker inspect --format='{{json .State.Health}}' mjrwtf | jq

The health check configuration:

  • Interval: 30 seconds
  • Timeout: 3 seconds
  • Start Period: 5 seconds
  • Retries: 3

Expected image sizes:

  • Without GeoIP database: ~20-30MB
  • With GeoIP database: ~30-80MB (depending on database size)

Check image size:

Terminal window
docker images mjrwtf

The Docker image follows security best practices:

  1. Non-root User: Runs as user appuser (UID 1000)
  2. Minimal Base: Uses Alpine Linux for small attack surface
  3. No Secrets: Secrets are passed via environment variables, not baked in
  4. Read-only GeoIP: GeoIP database mounted read-only when used
  5. Health Checks: Monitors application health
Terminal window
# Check logs
docker logs mjrwtf
# Common issues:
# - Missing AUTH_TOKENS/AUTH_TOKEN environment variable
# - Invalid DATABASE_URL
# - Database connection failed
Terminal window
# Check if the service is responding
docker exec mjrwtf curl -f http://localhost:8080/health
# Check application logs
docker logs mjrwtf
Terminal window
# Use a different port
docker run -p 9090:8080 mjrwtf:latest

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:

Terminal window
docker run --rm \
-v ./data:/app/data \
-e DATABASE_URL=/app/data/database.db \
mjrwtf:latest ./migrate up

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).

For production:

  1. Use specific image tags (not latest)
  2. Set strong AUTH_TOKENS (recommended) or AUTH_TOKEN
  3. Configure proper CORS (ALLOWED_ORIGINS)
  4. Use LOG_FORMAT=json for structured logging
  5. Mount a volume for persistent SQLite data
  6. Use secrets management (Docker secrets, Kubernetes secrets, etc.)
  7. Run behind a reverse proxy (nginx, Traefik, etc.)

Example production run:

Terminal window
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