Cloud Native 34 min read

Master Dockerizing Go Apps: From Local Development to Production‑Ready Deployment

This step‑by‑step guide shows how to containerize a Go web project, covering project layout, Dockerfile creation (including multi‑stage builds), Docker Compose orchestration, Makefile automation, CI/CD with GitHub Actions, performance tuning, security checks, and production deployment best practices.

Ray's Galactic Tech
Ray's Galactic Tech
Ray's Galactic Tech
Master Dockerizing Go Apps: From Local Development to Production‑Ready Deployment

Project layout

The repository follows a conventional Go module layout:

go-web-demo/
├── cmd/server/main.go            # entry point
├── internal/handler/            # HTTP handlers
├── internal/service/            # business logic
├── internal/model/              # data models
├── internal/repository/         # data access
├── pkg/config/                  # configuration loader
├── pkg/logger/                  # structured logger
├── configs/config.yaml          # default configuration file
├── scripts/build.sh              # optional build helper
├── scripts/init.sql              # DB initialization script
├── Dockerfile                   # multi‑stage build definition
├── docker-compose.yml           # development compose file
├── docker-compose.prod.yml      # production overrides
├── Makefile                     # common automation targets
├── .github/workflows/ci-cd.yml   # GitHub Actions pipeline
└── README.md

Multi‑stage Dockerfile

A production‑ready Dockerfile builds the binary in a Go builder image and copies it into a minimal Alpine runtime image. It also creates a non‑root user, sets a health check, and uses CGO_ENABLED=0 for a static binary.

# ---------- Builder ----------
FROM golang:1.21-alpine AS builder
WORKDIR /build
RUN apk add --no-cache git ca-certificates tzdata
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
RUN go build -ldflags="-s -w" -trimpath -o /build/server ./cmd/server

# ---------- Runtime ----------
FROM alpine:3.19 AS runtime
RUN apk add --no-cache ca-certificates tzdata
RUN addgroup -g 1000 appgroup && \
    adduser -u 1000 -G appgroup -s /bin/sh -D appuser
WORKDIR /app
COPY --from=builder /build/server /app/server
COPY configs/config.yaml /app/configs/config.yaml
COPY scripts/init.sql /app/scripts/init.sql
RUN chown -R appuser:appgroup /app && chmod +x /app/server
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
ENTRYPOINT ["/app/server"]

Best‑practice checklist

Include a .dockerignore to reduce build context size.

Copy go.mod and go.sum before source files to maximise layer caching.

Combine related RUN commands to minimise the number of layers.

Consider a distroless base image for an even smaller footprint.

Pass build arguments ( VERSION, BUILD_TIME, GIT_COMMIT) to embed version information.

Docker Compose for multi‑service orchestration

The development compose file defines three core services (app, mysql, redis) and optional monitoring components (nginx, prometheus, grafana). Common environment variables and logging settings are reused via YAML anchors.

version: '3.8'

x-common-env: &common-env
  TZ: Asia/Shanghai
  LOG_LEVEL: info
  LOG_FORMAT: json

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        VERSION: ${VERSION:-dev}
        BUILD_TIME: ${BUILD_TIME:-}
        GIT_COMMIT: ${GIT_COMMIT:-}
    image: go-web-demo:${VERSION:-latest}
    container_name: go-web-app
    restart: unless-stopped
    ports:
      - "8080:8080"
    environment:
      <<: *common-env
      SERVER_PORT: 8080
      DATABASE_HOST: mysql
      DATABASE_PORT: 3306
      DATABASE_USER: root
      DATABASE_PASSWORD: root123456
      DATABASE_NAME: go_web_demo
      REDIS_HOST: redis
      REDIS_PORT: 6379
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - app-network
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M
    healthcheck:
      test: ["CMD","wget","--no-verbose","--tries=1","--spider","http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  mysql:
    image: mysql:8.0
    container_name: go-web-mysql
    restart: unless-stopped
    ports:
      - "3306:3306"
    environment:
      <<: *common-env
      MYSQL_ROOT_PASSWORD: root123456
      MYSQL_DATABASE: go_web_demo
    volumes:
      - mysql-data:/var/lib/mysql
      - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    networks:
      - app-network
    healthcheck:
      test: ["CMD","mysqladmin","ping","-h","localhost","-u","root","-proot123456"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: go-web-redis
    restart: unless-stopped
    ports:
      - "6379:6379"
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis-data:/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD","redis-cli","-a","${REDIS_PASSWORD}","ping"]
      interval: 10s
      timeout: 5s
      retries: 3

networks:
  app-network:
    driver: bridge

volumes:
  mysql-data:
    driver: local
  redis-data:
    driver: local
  app-logs:
    driver: local

Key points:

Use depends_on with health checks to guarantee service readiness.

Expose only the application port; external traffic can be routed through an optional Nginx reverse proxy.

Define resource limits and reservations to avoid noisy‑neighbor problems.

Makefile – one‑click build, test, and deployment

.PHONY: help build run test clean docker-build docker-up docker-down deploy

APP_NAME := go-web-demo
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DOCKER_REGISTRY := your-registry.com
DOCKER_IMAGE := $(DOCKER_REGISTRY)/$(APP_NAME):$(VERSION)

help:
	@echo "Available targets:"
	@echo "  build        - compile Go binary"
	@echo "  run          - run locally"
	@echo "  test         - unit tests"
	@echo "  docker-build - build Docker image"
	@echo "  docker-up    - start compose stack"
	@echo "  docker-down  - stop compose stack"
	@echo "  deploy       - build and push image"

build:
	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GitCommit=$(GIT_COMMIT)" -o bin/$(APP_NAME) ./cmd/server

run:
	go run ./cmd/server

test:
	go test -v -race -coverprofile=coverage.out ./...

clean:
	rm -rf bin/ coverage.out

docker-build:
	docker build --build-arg VERSION=$(VERSION) \
	    --build-arg BUILD_TIME=$(BUILD_TIME) \
	    --build-arg GIT_COMMIT=$(GIT_COMMIT) -t $(DOCKER_IMAGE) .

docker-up:
	VERSION=$(VERSION) docker-compose up -d

docker-down:
	docker-compose down

deploy: docker-build
	docker push $(DOCKER_IMAGE)

GitHub Actions CI/CD pipeline

name: CI/CD Pipeline
on:
  push:
    branches: [main, develop]
    tags: ['v*']
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.21'
      - uses: golangci/golangci-lint-action@v3
        with:
          version: latest
          args: --timeout=5m

  test:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.21'
      - run: go test -v -race -coverprofile=coverage.out ./...
      - uses: codecov/codecov-action@v3
        with:
          file: ./coverage.out

  build:
    runs-on: ubuntu-latest
    needs: test
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: docker/setup-buildx-action@v3
      - name: Login to Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        run: |
          VERSION=${{ github.ref_name }}
          if [[ ${{ github.ref }} == refs/tags/* ]]; then
            VERSION=${VERSION#v}
          elif [[ ${{ github.ref }} == refs/heads/main ]]; then
            VERSION=latest
          else
            VERSION=${GITHUB_SHA::8}
          fi
          echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}" >> $GITHUB_OUTPUT
          echo "version=${VERSION}" >> $GITHUB_OUTPUT
      - name: Build and Push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          build-args: |
            VERSION=${{ steps.meta.outputs.version }}
            BUILD_TIME=${{ github.event.head_commit.timestamp }}
            GIT_COMMIT=${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64

  integration-test:
    runs-on: ubuntu-latest
    needs: build
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: test123
          MYSQL_DATABASE: test_db
        options: >-
          --health-cmd "mysqladmin ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.21'
      - run: |
          go test -v -tags=integration ./tests/integration/...

  deploy:
    runs-on: ubuntu-latest
    needs: [build, integration-test]
    if: startsWith(github.ref, 'refs/tags/')
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy via SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /opt/go-web-demo
            docker-compose pull
            docker-compose up -d
            docker system prune -f
      - name: Health Check
        run: |
          sleep 10
          curl -f https://your-domain.com/health || exit 1
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {"text": "Deploy ${{ github.ref_name }} ${{ job.status }}", "status": "${{ job.status }}"}
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

golangci‑lint configuration

run:
  timeout: 5m
  tests: true
linters:
  enable:
    - gofmt
    - govet
    - errcheck
    - staticcheck
    - unused
    - gosimple
    - structcheck
    - varcheck
    - ineffassign
    - deadcode
    - typecheck
    - gosec
    - gocritic
    - bodyclose
    - whitespace
    - misspell
linters-settings:
  gosec:
    severity: medium
  gocritic:
    enabled-tags:
      - diagnostic
      - experimental
      - opinionated
      - performance
      - style
issues:
  exclude-rules:
    - path: _test\.go
      linters:
        - errcheck

Production deployment checklist

Use the multi‑stage Dockerfile with a non‑root user.

Set environment variables for timezone, log level, and GOMAXPROCS in docker-compose.prod.yml.

Define resource limits (CPU, memory) and replica count for high availability.

Expose the application only internally; front‑end traffic should go through Nginx.

Enable health checks and rolling updates via docker-compose up --no-deps --build.

Run a deployment script that backs up the current version, pulls the latest code, updates the image, and validates health before completing.

Monitoring and alerting (Prometheus & Alertmanager)

Prometheus scrapes the Go application, MySQL, and Redis. Alert rules fire on high error rates, latency spikes, or container downtime.

# monitoring/prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s
scrape_configs:
  - job_name: 'go-app'
    static_configs:
      - targets: ['app:8080']
    metrics_path: /metrics
  - job_name: 'mysql'
    static_configs:
      - targets: ['mysql:3306']
  - job_name: 'redis'
    static_configs:
      - targets: ['redis:6379']
rule_files:
  - "alerts.yml"
alerting:
  alertmanagers:
    - static_configs:
        - targets: ['alertmanager:9093']

# monitoring/alerts.yml
groups:
  - name: app_alerts
    rules:
      - alert: HighErrorRate
        expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.1
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "High error rate detected"
          description: "Error rate is {{ $value }} errors/sec"
      - alert: HighLatency
        expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High latency detected"
          description: "P95 latency is {{ $value }}s"
      - alert: ContainerDown
        expr: up == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Container {{ $labels.instance }} is down"

FAQ – common problems and solutions

Container exits immediately : The binary may have terminated or not run in foreground. Check logs with docker logs <container> and ensure the ENTRYPOINT uses exec form (e.g., ["/app/server"]).

Cannot connect to database : Verify depends_on health checks, network configuration, and that DATABASE_HOST points to the MySQL service name.

Slow image build : Optimize Dockerfile ordering, use a .dockerignore, and enable BuildKit ( DOCKER_BUILDKIT=1).

Container time is wrong : Set the TZ environment variable or mount /etc/localtime into the container.

Missing logs : Write logs to stdout/stderr and configure the Docker daemon with the json-file driver and rotation options.

Memory leak in Go app : Use pprof for profiling, adjust GOMAXPROCS, and set appropriate memory limits in Compose.

Signal not handled : Use exec‑form ENTRYPOINT so that SIGTERM is received by the Go process.

Final checklist before release

Dockerfile uses multi‑stage build and a non‑root user.

.dockerignore removes unnecessary files.

Health check is defined for the app service.

CPU and memory limits are set.

Logs are emitted to stdout in JSON format.

Graceful shutdown handling is implemented.

Image tags are immutable (e.g., v1.2.3).

Run vulnerability scanning (e.g., docker scan or Trivy).

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

DockerCI/CDcontainerizationProduction
Ray's Galactic Tech
Written by

Ray's Galactic Tech

Practice together, never alone. We cover programming languages, development tools, learning methods, and pitfall notes. We simplify complex topics, guiding you from beginner to advanced. Weekly practical content—let's grow together!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.