What Is Docker and Why Does It Matter?#

When you write code on your laptop, it works. When you deploy it to a server, it breaks. Different OS version, different library versions, different environment variables, different file paths. “It works on my machine” is one of the oldest problems in software.

Docker solves this by packaging your application with everything it needs to run — the OS libraries, the runtime, the config — into a single image. That image runs identically everywhere: your laptop, a CI server, a production server in a data center.

Container vs Image:

  • Image — a read-only template. Like a class definition.
  • Container — a running instance of an image. Like an object.

You build an image once, push it to a registry, and pull+run it anywhere.

Your First Dockerfile#

A Dockerfile defines how to build an image:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Start from the official Go image (contains the Go compiler)
FROM golang:1.24-alpine AS builder

WORKDIR /app

# Copy dependency files first (for layer caching)
COPY go.mod go.sum ./
RUN go mod download

# Copy source and build
COPY . .
RUN go build -o /app/theron ./main.go

# ── Final image — much smaller, no compiler ──────────────────────────
FROM alpine:3.21

# Install runtime dependencies only
RUN apk add --no-cache ca-certificates

COPY --from=builder /app/theron /usr/local/bin/theron

EXPOSE 50051 50052

ENTRYPOINT ["theron"]
CMD ["serve"]

This is a multi-stage build. The first stage (builder) uses the full Go image to compile the binary. The second stage (FROM alpine) starts fresh with a tiny base image and only copies the compiled binary. The final image is ~15MB instead of ~600MB.

Build and run:

1
2
docker build -t theron:latest .
docker run -p 50051:50051 -p 50052:50052 theron:latest

Docker Compose: Multiple Services Together#

Most applications need more than one container — a Go backend, ClickHouse, Redis. Docker Compose defines them together:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# docker-compose.yml
version: '3.9'

services:
  clickhouse:
    image: clickhouse/clickhouse-server:24.3
    ports:
      - "9000:9000"   # native protocol (Go driver)
      - "8123:8123"   # HTTP interface
    environment:
      CLICKHOUSE_USER: developer
      CLICKHOUSE_PASSWORD: password
      CLICKHOUSE_DB: binance_data
    volumes:
      - clickhouse_data:/var/lib/clickhouse
    healthcheck:
      test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s

volumes:
  clickhouse_data:
  redis_data:
1
2
3
docker compose up -d --wait    # start all services, wait for health checks
docker compose down            # stop and remove containers
docker compose logs -f redis   # stream logs from one service

--wait waits until all health checks pass before returning. This means make docker-up && make run won’t fail because ClickHouse isn’t ready yet.

Docker Hub: The Public Registry#

Docker Hub is Docker’s default registry — where images are stored and shared. The official images you use (golang:1.24, redis:7-alpine, alpine:3.21) all come from Docker Hub.

Push your own image:

1
2
3
4
5
6
7
8
# Tag with your Docker Hub username
docker tag theron:latest kenura/theron:latest
docker tag theron:latest kenura/theron:v1.2.0

# Login and push
docker login
docker push kenura/theron:latest
docker push kenura/theron:v1.2.0

Now anyone can run your application with:

1
2
docker pull kenura/theron:latest
docker run kenura/theron:latest

Docker Hub free tier: unlimited public repositories, 1 private repository. For private images, you either pay for Docker Hub Pro or use an alternative registry.

GitHub Container Registry: Docker Hub Alternative#

If your code is already on GitHub, GitHub Container Registry (GHCR) is the natural choice for private images. It’s integrated with GitHub permissions — your team’s GitHub access automatically extends to your private images.

1
2
3
4
5
6
7
8
# Login with your GitHub personal access token
echo $GITHUB_TOKEN | docker login ghcr.io -u kenura --password-stdin

# Tag with ghcr.io prefix
docker tag theron:latest ghcr.io/kenura/theron:latest

# Push
docker push ghcr.io/kenura/theron:latest

GHCR advantages over Docker Hub:

  • Integrated with GitHub Actions — no separate credentials to manage
  • Private packages included in all GitHub plans (free for public repos)
  • Package visibility controlled by repository permissions
  • Automatic cleanup of old images via retention policies

GHCR disadvantages:

  • Tied to GitHub (Docker Hub is provider-independent)
  • Rate limits on public pulls (same as Docker Hub, but less well-documented)

GitHub Actions: Full CI/CD Pipeline#

GitHub Actions runs automated workflows on every push, PR, or tag. Here’s a complete pipeline that:

  1. Runs tests on every push
  2. Builds and pushes a Docker image on every merge to main
  3. Builds a release image on every git tag
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# .github/workflows/ci.yml
name: CI/CD

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}  # → ghcr.io/kenura/theron

jobs:
  # ── Test ─────────────────────────────────────────────────────────────
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.24'
          cache: true  # cache go modules between runs

      - name: Run tests
        run: go test ./... -race -count=1

      - name: Run linter
        uses: golangci/golangci-lint-action@v6

  # ── Build and Push Docker Image ──────────────────────────────────────
  build-push:
    needs: test
    runs-on: ubuntu-latest
    # Only run on push to main or tags, not on PRs
    if: github.event_name != 'pull_request'

    permissions:
      contents: read
      packages: write  # needed to push to GHCR

    steps:
      - uses: actions/checkout@v4

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}  # auto-provided, no setup needed

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            # main branch → :latest
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
            # git tags → :v1.2.0
            type=semver,pattern={{version}}
            # every commit → :sha-abc1234
            type=sha,prefix=sha-

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha   # use GitHub Actions cache for Docker layers
          cache-to: type=gha,mode=max

Key details:

secrets.GITHUB_TOKEN — GitHub automatically provides this secret. You don’t need to create it. It has permissions to push to GHCR for the repository it’s running in.

cache-from: type=gha — caches Docker build layers in GitHub Actions cache. The next build reuses unchanged layers, making builds much faster (especially for the go mod download step).

docker/metadata-action — generates image tags automatically: latest for main branch, semver tags for git tags, SHA tags for every commit.

Deploying from CI/CD#

After the image is pushed, deployment depends on your infrastructure:

Simple: SSH + Docker pull

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  deploy:
    needs: build-push
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Deploy to server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ghcr.io/kenura/theron:latest
            docker compose up -d --force-recreate theron

DigitalOcean App Platform — connect your GitHub repo, point at the Dockerfile, and it automatically deploys on every push to main. Zero server management.

Kubernetes — update the image tag in your deployment manifest and apply. CI/CD tools like ArgoCD or Flux watch the manifest repo and apply changes automatically.

Docker Hub vs GHCR vs Other Registries#

RegistryFree privateAuthBest for
Docker Hub1 repoDocker credentialsPublic images, standard tooling
GHCRUnlimitedGitHub tokenGitHub-hosted projects
AWS ECRYes (500MB)AWS IAMAWS deployments
Google Artifact RegistryYesGCP service accountGCP deployments
Self-hosted (Registry v2)UnlimitedCustomFull control, on-premise

For a project hosted on GitHub deploying to DigitalOcean or a VPS: GHCR is the simplest choice. No extra credentials, automatic permission inheritance from GitHub, free private repos.

For maximum portability (not tied to GitHub): Docker Hub with a paid account, or a self-hosted registry.

What Gets Cached and Why It Matters#

Docker builds layer-by-layer. Each RUN, COPY, and ADD instruction creates a layer. Layers are cached — if the instruction and its inputs haven’t changed, Docker reuses the cached layer.

This is why the Dockerfile copies go.mod and go.sum before copying the source:

1
2
3
4
5
6
# ✓ Correct order — go mod download is cached unless go.mod changes
COPY go.mod go.sum ./
RUN go mod download         # only re-runs when go.mod/go.sum change

COPY . .
RUN go build -o /theron .   # re-runs when any source file changes
1
2
3
4
# ✗ Wrong order — go mod download re-runs on every source change
COPY . .
RUN go mod download         # invalidated by every source change
RUN go build -o /theron .

Layer ordering is the single biggest impact on build speed. Always put things that change rarely (dependency files, base configuration) before things that change often (source code).