Deploying Go Apps with Docker and CI/CD Pipelines

Deploying Go Apps with Docker and CI/CD
Go Deployment: Quick Answer
A Go application compiles to a single static binary with no runtime dependencies. A multi-stage Docker build uses a golang:alpine builder image to compile the binary, then copies only that binary into a minimal alpine or scratch base image — producing a production container as small as 15MB. A GitHub Actions CI/CD pipeline then automates running tests, building the image, and deploying on every push to main.
Deploying Go Apps: Docker & CI/CD
Go is the undisputed king of cloud-native development. Because Go produces a single, statically-linked binary, it is perfectly suited for containerization. Unlike Python or Node.js, you Don't need to ship a massive runtime environment—you can ship just your binary.
In this module, we will explore how to "Dockerize" your Go application and deploy it with confidence using CI/CD.
Go's Deployment Advantage
Multi-Stage Docker Builds
This is the secret to Go's tiny images. We use a heavy "builder" image to compile the code, and then copy the resulting binary into a clean, minimal "scratch" or "alpine" image for production.
Automating with CI/CD
Continuous Integration and Continuous Deployment (CI/CD) ensure that every time you push code, it is automatically tested, built into a Docker image, and deployed to your servers.
CI/CD Pipeline Stages
go test ./...Runs your unit tests and benchmarks to ensure the new code hasn't introduced any regressions.
golangci-lintAnalyzes your code for stylistic issues and common bugs before it even reaches the build stage.
docker buildCompiles your code into a production-ready image and pushes it to a private container registry.
kubectl applyUpdates your production environment (AWS, GCP, DigitalOcean) with the new container version.
Environment Secrets
Never hardcode your database passwords or API keys in your code or Dockerfile. Use environment variables. In a production environment, use a secret manager like AWS Secrets Manager or GitHub Secrets.
| Task / Feature | Standard Deploy | Go Docker Deploy |
|---|---|---|
| Server Setup | Install Go, Install Deps, Git Pull | Install Docker (runs anywhere) |
| Startup Speed | Slow (Requires build/fetch) | Near-instant (Starts the pre-built binary) |
| Portability | Depends on server OS/version | Consistent every single time |
A Complete GitHub Actions CI/CD Workflow
Here is a production-ready GitHub Actions workflow that tests, builds, and pushes a Docker image on every push to main:
The needs: test directive ensures the Docker image is only built and pushed if all tests pass. The image is tagged with both latest and the Git commit SHA — the SHA tag gives you a precise record of which code version is in production.
Docker Compose for Local Development
While the multi-stage Dockerfile is for production, use Docker Compose to run your entire local stack (application + database + Redis) with a single command:
Run docker compose up to start everything. Developers on any OS get an identical environment, eliminating "works on my machine" issues.
Health Checks and Graceful Shutdown
Production containers should implement a health check endpoint and graceful shutdown to handle zero-downtime deployments:
Your Docker Compose and Kubernetes health check configuration should poll /health. Kubernetes only routes traffic to pods that pass the health check, ensuring zero-downtime rolling deployments.
External Resources
- Docker multi-stage builds documentation
- GitHub Actions documentation
- Go official installation guide — go.dev
For the full context of this module in the Go series, see Go caching with Redis (the step before deployment) and Go security best practices (the step after). For AI-assisted code review that fits into this CI/CD pipeline, see building a GitHub PR review agent with Claude.
Common Mistakes and How to Avoid Them
Deploying Go applications with Docker and CI/CD introduces a specific set of failure modes. Here are five mistakes to avoid.
1. Running containers as root. By default, Docker containers run as root. If an attacker escapes the container, they have root on the host. Add a non-root user to your Dockerfile: RUN adduser -D appuser && USER appuser. Most production security scanners flag root containers as a critical vulnerability.
2. Including secrets in Docker image layers. Environment variables and RUN commands used to COPY secrets at build time leave those secrets baked into the image layers, even if you delete them later in the same stage. Always inject secrets at runtime via environment variables or a secrets manager — never at build time.
3. Using latest as the image tag in production. The latest tag is mutable — a new push can silently change what version you are running. Tag every production image with the Git commit SHA (myapp:${{ github.sha }}). This gives you a precise audit trail and enables rollback by deploying a specific SHA tag.
4. Missing the CGO_ENABLED=0 flag. Without CGO_ENABLED=0, Go may produce a dynamically linked binary that depends on libc. When you copy this binary into a scratch or alpine image that lacks libc, the container crashes at startup with exec format error. Always build with CGO_ENABLED=0 GOOS=linux in your Dockerfile.
5. Not testing the health check endpoint. A health check endpoint that always returns 200 provides no value. Wire the handler to a real dependency check: confirm the database connection pool is alive and Redis is reachable. Kubernetes and load balancers use this signal to stop routing traffic to a degraded pod. The Go net/http package makes it straightforward to implement lightweight dependency checks inline.
FAQ
Q: Should I use scratch or alpine as my production base image?
scratch produces the smallest possible image (just your binary) but offers no shell for debugging. alpine adds ~5MB and a minimal shell, making it easier to exec into a running container during an incident. For security-critical production services, scratch or gcr.io/distroless/static is preferred. For most applications, alpine offers the right balance of size and debuggability.
Q: How do I handle database migrations in a CI/CD pipeline?
Run migrations as a separate Job in your pipeline before deploying the new application version, using a tool like golang-migrate. The migration job should run migrate up against the production database connection, and the deploy job should depend on (needs: migrate) so the new schema is in place before the new binary starts.
Q: How should I structure secrets across local development and production?
Use a .env file loaded by godotenv locally (never committed to version control), and GitHub Secrets or AWS Secrets Manager in production. The key principle — consistent with Go security best practices — is that the same environment variable name is used in all environments, so no code changes are needed when promoting from staging to production.
Next Steps
Your application is now live! But a live application is a target. In our penultimate tutorial, we will explore Go Security Best Practices, learning how to harden your backend against modern attacks like SQL injection and cross-site scripting.
Common Go Deployment Mistakes
1. Not using a multi-stage Docker build
Building Go inside a single Docker image leaves the compiler, source code, and build cache in the final image — bloating it to 800MB+. Use a multi-stage build: compile in golang:1.22 and copy only the binary into scratch or alpine. Final image size drops to under 20MB.
2. Running as root inside the container
Go binaries do not need root. Add USER nonroot:nonroot in your Dockerfile after copying the binary. Running as root unnecessarily increases the blast radius if the container is compromised.
3. Not setting CGO_ENABLED=0 for static builds
CGO_ENABLED=0 GOOS=linux go build produces a fully static binary with no shared library dependencies. Without this, your binary may fail to run in a scratch or minimal Alpine container that lacks the required libc.
4. Hardcoding configuration instead of using environment variables
Port numbers, database URLs, and feature flags hardcoded in the binary require a rebuild for every environment change. Use os.Getenv for all environment-specific configuration and document required variables in a .env.example file.
5. Missing health check endpoints
Kubernetes and other orchestrators use liveness and readiness probes to determine if a pod should receive traffic. Always implement /healthz (liveness) and /readyz (readiness) endpoints that return 200 when the service is healthy. See the Kubernetes health check documentation.
Frequently Asked Questions
What is the recommended base image for a Go Docker container?
scratch (empty image) produces the smallest possible image but requires a fully static binary and no file system access. gcr.io/distroless/static-debian12 adds CA certificates and timezone data without a shell — the best balance of security and functionality for most Go services. alpine is also popular but adds a package manager attack surface.
How do I handle graceful shutdown in a Go HTTP server?
This waits up to 30 seconds for in-flight requests to complete before exiting.
What CI/CD tools work well with Go? GitHub Actions with the official Go setup action is the most common choice. GitLab CI and CircleCI also have excellent Go support. For container builds, Docker Buildx with BuildKit caching significantly reduces build times by caching the Go module download layer.
