Files
docs/DOCKER.md
2026-07-28 07:20:32 -05:00

15 KiB

Docker Build Guide

Multi-stage Docker builds for every Arcline web service — small images, no CVEs, non-root execution.


Table of Contents


Philosophy

  1. Minimal base images — Alpine Linux (~5MB) as the runtime base. No Ubuntu, no Debian, no unnecessary packages.
  2. Multi-stage builds — The Go toolchain is only in the build stage. The runtime stage contains only the compiled binary + ca-certificates.
  3. Non-root execution — Every container runs as the arcline unprivileged user. No root in production.
  4. Immutable tags — Each image is tagged by commit SHA. latest is a convenience pointer, never relied upon for production consistency.
  5. SQLite-friendly — Databases are stored on mounted volumes (ephemeral by design), but the binary is fully self-contained.

Multi-Stage Build Pattern

Every Arcline Dockerfile follows this exact structure:

┌────────────────────────────────────────────┐
│  Stage 1: builder                           │
│  Base: golang:1.xx-alpine                   │
│                                              │
│  1. Set WORKDIR /build                       │
│  2. COPY go.mod go.sum → download deps      │
│  3. COPY source code                        │
│  4. CGO_ENABLED=0 go build                  │
└────────────────────┬───────────────────────┘
                     │
                     │ COPY --from=builder
                     ▼
┌────────────────────────────────────────────┐
│  Stage 2: runtime                           │
│  Base: alpine:3.xx                          │
│                                              │
│  1. apk add ca-certificates                  │
│  2. Create arcline user & group             │
│  3. COPY binary + static assets             │
│  4. chown to arcline                        │
│  5. USER arcline                            │
│  6. ENV PORT=8080                           │
│  7. EXPOSE 8080                             │
│  8. CMD ["./<binary>"]                      │
└────────────────────────────────────────────┘

Rationale

Design Decision Why
golang:alpine builder Smallest Go build image (~300MB vs 1GB+ for debian-based)
alpine:3.xx runtime ~5MB base, minimal CVEs, musl libc compatibility
ca-certificates Required for TLS connections (Stripe, Let's Encrypt, etc.)
CGO_ENABLED=0 Fully static binary — no libc dependencies at runtime
-trimpath Removes build machine paths from binary
-ldflags="-s -w" Strips debug info — smaller binary
Non-root user Security best practice — limits blast radius

Existing Dockerfiles

Summary

Service Path Go Version Alpine Version Port Binary Static Assets
portal portal/Dockerfile 1.25 3.21 8080 arcline-portal static/
billing billing/Dockerfile 1.22 3.21 8082 billing static/
website website/Dockerfile 1.22 3.19 8081 arcline-web static/, templates/
docs (dynamic) docs/Dockerfile 1.22 3.20 8080 docs-server content/, static/, templates/
docs (static) docs/Dockerfile.static 1.22 nginx:alpine 80 — (pre-built HTML) dist/

Portal — portal/Dockerfile

# ── Stage 1: Build ─────────────────────────────────────────────────────────
FROM golang:1.25-alpine AS builder

WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o arcline-portal .

# ── Stage 2: Runtime ────────────────────────────────────────────────────────
FROM alpine:3.21 AS runtime

RUN apk add --no-cache ca-certificates && \
    addgroup -S arcline && \
    adduser -S -G arcline arcline

WORKDIR /app

COPY --from=builder /build/arcline-portal ./
COPY --from=builder /build/static ./static

RUN chown -R arcline:arcline /app

USER arcline

ENV PORT=8080

EXPOSE 8080

CMD ["./arcline-portal"]

Key features:

  • Go 1.25 for latest stdlib improvements
  • Alpine 3.21 runtime
  • Port 8080 (mapped to 8082 in production via nginx or docker -p flag)
  • Embedded static/ directory

Billing — billing/Dockerfile

# ── Stage 1: Build ─────────────────────────────────────────────────────────
FROM golang:1.22-alpine AS builder

WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o billing .

# ── Stage 2: Runtime ────────────────────────────────────────────────────────
FROM alpine:3.21 AS runtime

RUN apk add --no-cache ca-certificates && \
    addgroup -S arcline && \
    adduser -S -G arcline arcline

WORKDIR /app

COPY --from=builder /build/billing ./
COPY --from=builder /build/static ./static

RUN chown -R arcline:arcline /app

USER arcline

ENV PORT=8082

EXPOSE 8082

CMD ["./billing"]

Website — website/Dockerfile

# ── Stage 1: Build ─────────────────────────────────────────────────────────
FROM golang:1.22-alpine AS builder

WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o arcline-web .

# ── Stage 2: Runtime ────────────────────────────────────────────────────────
FROM alpine:3.19 AS runtime

RUN apk add --no-cache ca-certificates && \
    addgroup -S arcline && \
    adduser -S -G arcline arcline

WORKDIR /app

COPY --from=builder /build/arcline-web ./
COPY --from=builder /build/static ./static
COPY --from=builder /build/templates ./templates

RUN chown -R arcline:arcline /app

USER arcline

ENV PORT=8081

EXPOSE 8081

CMD ["./arcline-web"]

Note: The website has a separate deployment path for OpenBSD (native binary, not Docker). The Dockerfile exists for Linux-based testing/staging.

Docs (Dynamic) — docs/Dockerfile

FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o docs-server ./cmd/serve

FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /build/docs-server .
COPY content  ./content
COPY static   ./static
COPY templates ./templates

EXPOSE 8080

ENV PORT=8080 \
    CONTENT_DIR=/app/content \
    STATIC_DIR=/app/static \
    TEMPLATES_DIR=/app/templates

CMD ["./docs-server"]

Docs (Static) — docs/Dockerfile.static

# Stage 1: build static HTML from Markdown
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go run ./cmd/build

# Stage 2: serve with nginx
FROM nginx:alpine
COPY --from=builder /build/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Docker Compose

Docs — docs/docker-compose.yml

services:
  docs:
    build: .
    ports:
      - "8080:8080"
    environment:
      PORT: "8080"
      ADMIN_EMAIL: "${ADMIN_EMAIL}"
      BILLING_URL: "${BILLING_URL:-https://portal.arcline.it}"
      BILLING_DB: /data/billing/arcline-billing.db
      DOCS_DB: /data/docs/arcline-docs.db
    volumes:
      - billing-data:/data/billing:ro
      - docs-data:/data/docs
    restart: unless-stopped

volumes:
  billing-data:
    external: true
    name: arcline_billing_data
  docs-data:

Notes:

  • Docs depends on arcline_billing_data volume from the billing service
  • This is the only service with Docker Compose — others use docker run directly

Image Sizes

Current Estimates

Image Build Stage Runtime Final Size
portal golang:1.25-alpine (~350MB) alpine:3.21 (~5MB) ~15-20MB
billing golang:1.22-alpine (~350MB) alpine:3.21 (~5MB) ~15-20MB
website golang:1.22-alpine (~350MB) alpine:3.19 (~5MB) ~20-25MB
docs (dynamic) golang:1.22-alpine (~350MB) alpine:3.20 (~5MB) ~20-30MB
docs (static) golang:1.22-alpine (~350MB) nginx:alpine (~25MB) ~30-40MB

Optimization tips:

  • Run go mod tidy before building to remove unused dependencies
  • Use -ldflags="-s -w" to strip debug symbols (saves ~30% binary size)
  • Use .dockerignore to exclude unnecessary files from build context

Security Practices

Container Security

# 1. Non-root user
RUN addgroup -S arcline && adduser -S -G arcline arcline
USER arcline

# 2. Minimal packages
RUN apk add --no-cache ca-certificates
# NO: bash, curl, wget, openssl, etc. in production

# 3. Read-only root filesystem
# (set at runtime: --read-only)
docker run --read-only --tmpfs /tmp ...

# 4. Drop all capabilities
docker run --cap-drop ALL ...

# 5. No privilege escalation
# (set at runtime)
docker run --security-opt no-new-privileges ...

Production Docker Run

docker run -d \
  --name <service> \
  --restart unless-stopped \
  --read-only \
  --tmpfs /tmp:noexec,nosuid,size=64M \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  -p 127.0.0.1:<port>:<port> \
  -v /opt/<service>/.env:/app/.env:ro \
  -v /opt/<service>/data:/app/data \
  <image>:latest

.dockerignore

Every service should have a .dockerignore that excludes:

.git/
.gitignore
*.md
*.db          # Don't bundle local databases
.env          # Don't bundle secrets
<binary>      # Don't bundle pre-built binaries
<binary>-linux-*

Development Workflow

Local Build & Test

# Build Docker image
docker build -t <service>:dev .

# Run with local .env
docker run -d \
  --name <service>-dev \
  -p 8080:8080 \
  -v $(pwd)/.env:/app/.env:ro \
  <service>:dev

# Check logs
docker logs <service>-dev -f

# Stop and clean up
docker stop <service>-dev
docker rm <service>-dev

Hot Reload (Development Only)

# Build and run in one command
docker build -t <service>:dev . && \
docker rm -f <service>-dev 2>/dev/null; \
docker run -d --name <service>-dev -p 8080:8080 <service>:dev && \
docker logs -f <service>-dev

Registry & Tagging

GitLab Container Registry

All Arcline images are stored in the self-hosted GitLab Container Registry.

# Login
docker login registry.arcline.it

# Tag for registry
docker tag <service>:latest registry.arcline.it/<project>/<service>:<tag>

# Push
docker push registry.arcline.it/<project>/<service>:latest

Tagging Convention

Tag Source Use
$CI_COMMIT_SHORT_SHA CI Unique deploy artifact
latest CI Latest successful main branch build
vX.Y.Z Manual Versioned release
dev Local Development builds

Dockerfile Templates

Web Service Template

For services that serve HTTP (portal, billing, website, docs):

# ── Stage 1: Build ─────────────────────────────────────────────────────────
FROM golang:1.xx-alpine AS builder

WORKDIR /build

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o <binary> .

# ── Stage 2: Runtime ────────────────────────────────────────────────────────
FROM alpine:3.xx AS runtime

RUN apk add --no-cache ca-certificates && \
    addgroup -S arcline && \
    adduser -S -G arcline arcline

WORKDIR /app

COPY --from=builder /build/<binary> ./
# If service has static assets:
# COPY --from=builder /build/static ./static
# If service has templates:
# COPY --from=builder /build/templates ./templates

RUN chown -R arcline:arcline /app

USER arcline

ENV PORT=8080

EXPOSE 8080

CMD ["./<binary>"]

CLI Tool Template

For CLI tools that don't need a web server (uptime, status, check, etc.):

# Usually CLI tools don't need Docker — they're installed as native binaries.
# If Docker is needed for isolation:

FROM alpine:3.xx

RUN apk add --no-cache ca-certificates && \
    addgroup -S arcline && \
    adduser -S -G arcline arcline

COPY <binary> /usr/local/bin/<binary>

USER arcline

ENTRYPOINT ["/usr/local/bin/<binary>"]

Troubleshooting

Build Issues

Symptom Cause Fix
go: downloading is slow No layer caching Move COPY go.mod go.sum before COPY .
CGO_ENABLED=0 build fails CGO dependency Use modernc.org/sqlite instead of mattn/go-sqlite3
exec ./binary: no such file Binary not in PATH Use ./binary or absolute path
standard_init_linux.go:... Wrong arch Build with GOARCH=amd64 for amd64 hosts
Image too large Debug symbols in binary Add -ldflags="-s -w"

Runtime Issues

Symptom Cause Fix
x509: certificate signed by unknown authority Missing ca-certificates Add apk add ca-certificates
permission denied Running as root Use USER arcline and chown
bind: address already in use Port conflict Change PORT env or host mapping
SQLite disk I/O error Read-only filesystem Mount writable volume at data path
Container exits immediately Binary crashes Run docker logs <container> to see error

Debugging a Container

# Enter a running container
docker exec -it <container> /bin/sh

# Copy files out of a container
docker cp <container>:/app/data/app.db ./app.db

# Run a one-shot command in the container
docker run --rm -it <image> /bin/sh