Files
pirate-station/.planning/phases/01-foundation/01-RESEARCH.md
acty ccb93eda21 docs(01): create phase 1 foundation plans
Phase 01: Foundation
- 2 plans in 2 waves
- Plan 01: Go project + Dockerfile (wave 1)
- Plan 02: Dev environment + verification (wave 2)
- Ready for execution

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 16:56:50 +09:00

18 KiB

Phase 1: Foundation - Research

Researched: 2026-02-03 Domain: Go backend containerization with Docker Confidence: HIGH

Summary

This phase establishes a Go HTTP backend running in an isolated Docker container with volume-mounted storage. The standard approach uses multi-stage Dockerfiles with golang:latest for building and debian:slim for runtime, deployed via Docker Compose for development and direct builds on the Raspberry Pi.

The research validates all locked decisions from the context discussion: Debian slim provides better compatibility than Alpine for cross-platform development, buildx handles multi-arch builds (x86_64 + ARM64) seamlessly, and Docker Compose with bind mounts enables fast iteration during local development. The high port (32768) avoids common service conflicts, and standard volume isolation provides adequate security without filesystem hardening overhead.

Current Go stable versions are 1.24 and 1.25 (Go 1.26 releases February 2026). Official Go project layout guidance recommends internal/ for private packages and cmd/ for multiple commands, with module paths matching repository URLs.

Primary recommendation: Use multi-stage Dockerfile with Go 1.25+, debian:slim base, buildx for multi-arch, and Air for development hot reload. Follow official Go module layout with cmd/ and internal/ directories.

Standard Stack

The established libraries/tools for this domain:

Core

Library Version Purpose Why Standard
Go 1.25+ Backend language Latest stable (1.24/1.25 supported)
Docker Buildx bundled Multi-arch builds Official Docker tool for cross-platform
Docker Compose v2 Dev orchestration Standard for local development workflows
golang:1.25 latest stable Build stage base Official Go image with full toolchain
debian:slim 12 (bookworm) Runtime base Balance of size, compatibility, debugging

Supporting

Library Version Purpose When to Use
Air latest Hot reload Development only - NOT for production
gcr.io/distroless/base latest Alternative runtime When minimal image size is critical
alpine:latest 3.19+ Alternative runtime When musl libc compatibility is acceptable

Alternatives Considered

Instead of Could Use Tradeoff
debian:slim gcr.io/distroless/base Smaller (~20MB vs ~80MB) but no shell for debugging
debian:slim alpine:latest Smaller (~5MB) but musl libc can cause unexpected behavior with net package
Manual builds GitHub Actions Automated but adds complexity for transitional dev environment

Installation:

# Go toolchain
# Download from https://go.dev/dl/ or use system package manager

# Docker Buildx (included in Docker Desktop, or install separately)
docker buildx version

# Air for hot reload (development only)
go install github.com/cosmtrek/air@latest

Architecture Patterns

pirate-station/
├── cmd/
│   └── server/           # Main application entry point
│       └── main.go
├── internal/             # Private application code
│   ├── server/          # HTTP server setup
│   ├── storage/         # /data volume operations
│   └── health/          # Health check handlers
├── docker/
│   └── Dockerfile       # Multi-stage build definition
├── docker-compose.yml   # Development environment
├── .air.toml           # Air hot reload config
├── .dockerignore       # Exclude from build context
├── go.mod
└── go.sum

Rationale:

  • cmd/server/ - Single command, follows official Go layout for commands
  • internal/ - Prevents external imports, recommended for server packages
  • docker/ - Separates infrastructure from code
  • Root-level compose file - Standard Docker Compose convention

Pattern 1: Multi-Stage Dockerfile with Cross-Compilation

What: Build stage compiles Go binary, runtime stage creates minimal production image When to use: Always - reduces image size by 95% and improves security

Example:

# Build stage - use native platform for speed
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder

WORKDIR /build

# Cache dependencies separately
COPY go.mod go.sum ./
RUN go mod download

# Copy source and build for target platform
COPY . .
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
    go build -ldflags="-w -s" -o /server ./cmd/server

# Runtime stage - debian slim for debugging
FROM debian:bookworm-slim

# Run as non-root
RUN useradd -u 10001 -m appuser
USER appuser

COPY --from=builder /server /usr/local/bin/server

# Volume mount point
VOLUME /data

EXPOSE 32768

CMD ["server"]

Source: Official Docker documentation - Multi-platform builds

Pattern 2: Docker Compose Development Environment

What: Volume-mounted source with hot reload for rapid iteration When to use: Local development only

Example:

# docker-compose.yml
services:
  backend:
    build:
      context: .
      dockerfile: docker/Dockerfile
      target: builder  # Stop at build stage
    command: air -c .air.toml  # Hot reload
    ports:
      - "32768:32768"
    volumes:
      - .:/workspace:cached  # Bind mount for live editing
      - data:/data          # Named volume for persistent data
    working_dir: /workspace
    environment:
      - CGO_ENABLED=0

volumes:
  data:

Source: Docker Compose documentation - Development best practices

Pattern 3: Health Check Endpoint

What: HTTP endpoint returning 200 when ready, 503 when unhealthy When to use: Required for production deployments, useful in development

Example:

// internal/health/handler.go
package health

import (
    "net/http"
    "os"
)

func Handler(w http.ResponseWriter, r *http.Request) {
    // Check data volume is accessible
    if _, err := os.Stat("/data"); os.IsNotExist(err) {
        w.WriteHeader(http.StatusServiceUnavailable)
        w.Write([]byte(`{"status":"unhealthy","reason":"data volume not mounted"}`))
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status":"healthy"}`))
}

Source: Patterns from heptiolabs/healthcheck and Kubernetes health checks guide

Anti-Patterns to Avoid

  • Latest tag in production: Always pin specific versions (golang:1.25-bookworm not golang:latest)
  • Single-stage Dockerfile: Bloats image with build tools, increases attack surface
  • Root user in container: Security risk - always create non-root user
  • Copying unnecessary files: Use .dockerignore to exclude .git, test files, etc.
  • Running Air in production: Hot reload tools are development-only

Don't Hand-Roll

Problems that look simple but have existing solutions:

Problem Don't Build Use Instead Why
Health check logic Custom health tracking Standard /health endpoint pattern Kubernetes/Docker expect specific format
Hot reload mechanism File watching + restart script Air (cosmtrek/air) Handles Go build errors, configurable, maintained
Build caching Manual cache dirs Docker BuildKit layer caching Optimized for dependencies (go.mod) vs source
Multi-arch builds Multiple Dockerfiles docker buildx with --platform Cross-compiles natively, single source of truth
Static binary linking Complex ldflags research CGO_ENABLED=0 + -ldflags="-w -s" Standard Go approach, strips debug symbols

Key insight: Docker's native tooling (BuildKit, buildx) and Go's built-in cross-compilation handle most complexity. Avoid custom shell scripts for build processes.

Common Pitfalls

Pitfall 1: Poor Layer Caching in Dockerfile

What goes wrong: Every code change triggers full dependency reinstall, dramatically slowing builds

Why it happens: Copying all source files before running go mod download invalidates cache when any file changes

How to avoid: Copy go.mod and go.sum first, download dependencies, then copy source code

Warning signs:

  • "Downloading dependencies" on every build even when go.mod unchanged
  • Multi-minute builds for trivial code changes

Example:

# BAD - invalidates cache on any file change
COPY . .
RUN go mod download

# GOOD - cache dependencies separately
COPY go.mod go.sum ./
RUN go mod download
COPY . .

Source: Docker best practices - Dockerfile optimization

Pitfall 2: Alpine + net Package DNS Issues

What goes wrong: DNS lookups fail or behave unexpectedly when using Alpine Linux base images

Why it happens: Alpine uses musl libc instead of glibc, affecting Go's net package behavior unless compiled with CGO_ENABLED=0

How to avoid:

  • Use Debian slim instead of Alpine (decision already made)
  • Or ensure CGO_ENABLED=0 for pure Go DNS resolver

Warning signs:

  • DNS resolution works locally but fails in Alpine container
  • Intermittent connection errors to external services

Source: Go package documentation and community reports - Alpine vs Debian comparison

Pitfall 3: CGO Enabled by Default in Cross-Compilation

What goes wrong: Cross-compiled binaries fail to run on target architecture with "file not found" errors despite binary existing

Why it happens: CGO is enabled by default, creating dynamic links to C libraries that may not exist on target system

How to avoid: Explicitly set CGO_ENABLED=0 in Dockerfile for static binary compilation

Warning signs:

  • Binary works on build machine but fails on target
  • "no such file or directory" error for binary that exists
  • Larger binary size than expected

Example:

# REQUIRED for portable binaries
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
    go build -o /server ./cmd/server

Source: Official Docker Go guide - Building Go images

Pitfall 4: Forgetting .dockerignore

What goes wrong: Huge build contexts, slow builds, sensitive files copied to image

Why it happens: Docker copies entire directory context by default, including .git, test data, and local builds

How to avoid: Create .dockerignore file early in development

Warning signs:

  • "Sending build context" takes seconds
  • Docker build transfers hundreds of MB
  • Accidentally including .env files

Example .dockerignore:

.git
.gitignore
README.md
.env
.env.local
*.md
.DS_Store
.air.toml
docker-compose.yml
.planning/

Source: Docker best practices - Common Dockerfile mistakes

Pitfall 5: Bind Mounting in Production

What goes wrong: Using docker-compose.yml from development in production, exposing host filesystem

Why it happens: Not separating development and production configurations

How to avoid:

  • Use bind mounts only in development (docker-compose.yml)
  • Use named volumes or direct mounts in production deployment
  • Never bind mount sensitive host directories

Warning signs:

  • Host files accessible from production container
  • Container can modify unexpected host paths
  • Security audit flags container filesystem access

Source: Docker security guidance - Volume mount security

Code Examples

Verified patterns from official sources:

Minimal HTTP Server with Health Check

// cmd/server/main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
)

func main() {
    http.HandleFunc("/health", healthHandler)
    http.HandleFunc("/", rootHandler)

    port := ":32768"
    log.Printf("Server starting on port %s", port)
    if err := http.ListenAndServe(port, nil); err != nil {
        log.Fatal(err)
    }
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
    // Verify data volume is mounted
    if _, err := os.Stat("/data"); os.IsNotExist(err) {
        w.WriteHeader(http.StatusServiceUnavailable)
        fmt.Fprintf(w, `{"status":"unhealthy"}`)
        return
    }
    w.WriteHeader(http.StatusOK)
    fmt.Fprintf(w, `{"status":"healthy"}`)
}

func rootHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Pirate Station API")
}

Source: Standard Go net/http patterns from go.dev documentation

Multi-Arch Build Command

# Create builder (one-time setup)
docker buildx create --name pirate-builder --bootstrap --use

# Build and push multi-arch image
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag gitea.local/acty/pirate-station:latest \
  --push \
  -f docker/Dockerfile \
  .

Source: Official Docker buildx documentation - Multi-platform builds

Air Configuration for Hot Reload

# .air.toml
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  args_bin = []
  bin = "./tmp/server"
  cmd = "go build -o ./tmp/server ./cmd/server"
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", ".planning", "docker"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html"]
  include_file = []
  kill_delay = "0s"
  log = "build-errors.log"
  poll = false
  poll_interval = 0
  rerun = false
  rerun_delay = 500
  send_interrupt = false
  stop_on_error = false

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  main_only = false
  time = false

[misc]
  clean_on_exit = false

[screen]
  clear_on_rebuild = false
  keep_scroll = true

Source: Air documentation - github.com/air-verse/air

Gitea Registry Push

# Login to Gitea container registry
docker login gitea.local

# Tag image with Gitea format
docker tag pirate-station:latest gitea.local/acty/pirate-station:latest

# Push to registry
docker push gitea.local/acty/pirate-station:latest

Source: Gitea container registry documentation - Gitea docs

State of the Art

Old Approach Current Approach When Changed Impact
Alpine for minimal size Debian slim for compatibility Ongoing shift 2024-2026 Better C library compatibility, easier debugging
Manual multi-arch builds docker buildx with --platform 2020+ (BuildKit) Single command for cross-platform
CGO enabled by default CGO_ENABLED=0 for containers Standard practice now True static binaries, better portability
golang:latest tag golang:1.25-bookworm pinned Best practice evolution Reproducible builds
docker build docker buildx build 2020+ (BuildKit default) Better caching, multi-platform support
Scratch runtime base Distroless or Debian slim 2022+ trend Balance of size and debuggability

Deprecated/outdated:

  • MAINTAINER instruction: Use LABEL maintainer= instead (deprecated Docker syntax)
  • Docker Compose v1: Use v2 with docker compose command (not docker-compose)
  • Go 1.21 and earlier: Out of support - use 1.24+ (two most recent major releases supported)

Open Questions

Things that couldn't be fully resolved:

  1. Gitea multi-arch manifest support

    • What we know: Gitea supports OCI-compliant container registry
    • What's unclear: Some GitHub issues report buildx multi-arch push creating "unknown architectures"
    • Recommendation: Test multi-arch push during phase implementation; fallback to building directly on Pi if issues arise
  2. Air performance in Docker on Pi

    • What we know: Air works well for development, not recommended for production
    • What's unclear: Performance impact of file watching on bind-mounted volumes on Raspberry Pi
    • Recommendation: Test in development, may fall back to manual rebuild if too slow
  3. Debian slim vs distroless tradeoff

    • What we know: User chose Debian slim for debugging, distroless is smaller
    • What's unclear: How often shell access is needed in practice
    • Recommendation: Start with Debian slim as decided, can switch to distroless later if shell not needed

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • Gitea multi-arch buildx issues - Community reports from GitHub issues, marked for validation during implementation
  • Alpine vs Debian tradeoffs - Multiple blog posts agree, but requires real-world testing for specific use case

Metadata

Confidence breakdown:

  • Standard stack: HIGH - Official documentation and current best practices verified
  • Architecture: HIGH - Official Go and Docker documentation provides clear patterns
  • Pitfalls: HIGH - Well-documented common issues with official solutions
  • Gitea integration: MEDIUM - Official docs available but multi-arch behavior needs testing

Research date: 2026-02-03 Valid until: 2026-04-03 (60 days - Docker and Go tooling relatively stable)