DEV Community

Cover image for **Production-Ready Go Docker Containers: Small, Secure, and Efficient Containerization Guide**
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

**Production-Ready Go Docker Containers: Small, Secure, and Efficient Containerization Guide**

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Let’s talk about putting a Go application into a Docker container that’s ready for production. I don’t mean just any container—I mean a small, secure, and efficient one. Over the years, I've learned that a bloated container can slow you down, increase costs, and introduce security risks. So, let me walk you through how I build them.

The goal is simple: package your application so it runs consistently anywhere, using as little space and memory as possible, without cutting corners on security. We'll start with a basic Go web application. Here’s what that might look like.

package main

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

func HealthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    fmt.Fprintf(w, `{"status":"healthy","timestamp":"%s"}`, time.Now().UTC().Format(time.RFC3339))
}

func MetricsHandler(w http.ResponseWriter, r *http.Request) {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)

    w.Header().Set("Content-Type", "text/plain")
    fmt.Fprintf(w, `# HELP go_memstats_alloc_bytes Number of bytes allocated
go_memstats_alloc_bytes %d
# HELP go_goroutines Number of goroutines
go_goroutines %d
`, m.Alloc, runtime.NumGoroutine())
}

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    http.HandleFunc("/health", HealthHandler)
    http.HandleFunc("/metrics", MetricsHandler)
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Go Application v1.0.0")
    })

    log.Printf("Server starting on port %s", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}
Enter fullscreen mode Exit fullscreen mode

This app has three endpoints: a root handler, a health check, and a simple metrics endpoint. It's a typical starting point. The real work begins with the Dockerfile. This is where many people go wrong by creating a single, bulky stage that includes the compiler, source code, and the final binary all in one image.

Instead, I use a method called a multi-stage build. Think of it like building a car in a factory and then shipping only the finished car, not the entire factory. The first stage is the builder stage, where we compile the Go code.

# Build stage
FROM golang:1.21-alpine AS builder

RUN apk add --no-cache git ca-certificates tzdata
RUN adduser -D -g '' appuser

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

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -a \
    -ldflags="-w -s -extldflags '-static' \
    -X main.version=$(git describe --tags --always) \
    -X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    -o /app .
Enter fullscreen mode Exit fullscreen mode

Notice a few things. I start with golang:1.21-alpine, which is a smaller base image. I install only what’s needed: git for version info, ca-certificates for SSL, and tzdata for timezone support. I also create a non-root user called appuser right here in the builder stage. This is a security practice I always follow; the application should not run as root.

The build flags are crucial. CGO_ENABLED=0 creates a static binary that doesn’t depend on C libraries from the operating system. The -ldflags="-w -s" strips debug symbols, making the binary smaller. The -X flags inject version and build time directly into the binary, which is helpful for tracking what’s running.

Now, here’s the magic of multi-stage. I can create a second stage that starts from a completely empty base, called scratch.

# Final stage
FROM scratch

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /etc/passwd /etc/passwd

WORKDIR /app
COPY --from=builder /app /app/app

USER appuser

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD ["/app/app", "health"]

EXPOSE 8080

ENV PORT=8080 \
    TZ=UTC

ENTRYPOINT ["/app/app"]
Enter fullscreen mode Exit fullscreen mode

The scratch image is literally empty. It contains no operating system, no shell, nothing. This is the ultimate minimal footprint. Into this empty container, I copy only what the static binary needs: the SSL certificates, timezone data, the user information file, and the binary itself. That’s it.

The result is an image that’s often just 5 to 10 megabytes. It starts in milliseconds. I once replaced a 1.2 GB development-style image with a 7 MB one like this, and the performance and security improvements were immediate.

But a small image isn’t useful if it’s insecure. Let’s talk about security hardening. I already mentioned the non-root user. Running as appuser means if someone finds a way into the container, they have very limited privileges. I also copy the /etc/passwd file just to define that user; the container won’t even have a shell for them to use.

Another layer is a security scan. I can add a dedicated scanning stage to my Dockerfile using a tool like Trivy.

# Security scan stage
FROM aquasec/trivy:latest AS scanner
COPY --from=builder /app /app
RUN trivy filesystem --severity HIGH,CRITICAL --no-progress /app
Enter fullscreen mode Exit fullscreen mode

This stage scans the compiled binary for known vulnerabilities before it ever gets into the final image. It acts as a gate in the build process. In a team setting, this can be enforced in your continuous integration pipeline.

Now, development and testing are just as important. I use Docker Compose to create a consistent local environment that mirrors the build stage.

services:
  app:
    build:
      context: .
      target: builder
    ports:
      - "8080:8080"
    environment:
      - PORT=8080
      - DEBUG=true
    volumes:
      - .:/build
      - go-mod-cache:/go/pkg/mod
    command: go run main.go

  test:
    build:
      context: .
      target: builder
    environment:
      - GO111MODULE=on
    volumes:
      - .:/build
      - go-mod-cache:/go/pkg/mod
    command: go test -v ./...
Enter fullscreen mode Exit fullscreen mode

This docker-compose.yml file lets me run the application in development with hot-reloading (go run), run my test suite, and even run linters, all within isolated containers that share a cached module directory. It keeps my local machine clean and ensures everyone on the team has the same experience.

Automation is key for consistency. I write a simple shell script to handle the build, test, scan, and push process.

#!/bin/bash
set -e

APP_NAME="go-app"
REGISTRY="registry.example.com"
VERSION=${1:-$(git describe --tags --always)}

echo "Building container ${APP_NAME}:${VERSION}"
docker build \
    --build-arg VERSION=${VERSION} \
    --tag ${APP_NAME}:${VERSION} \
    --tag ${APP_NAME}:latest \
    --target builder \
    -f Dockerfile .

echo "Running security scan"
docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    aquasec/trivy:latest \
    image --severity HIGH,CRITICAL ${APP_NAME}:${VERSION}

echo "Running tests"
docker run --rm ${APP_NAME}:${VERSION} go test -v -race ./...

echo "Building production image"
docker build \
    --build-arg VERSION=${VERSION} \
    --tag ${REGISTRY}/${APP_NAME}:${VERSION} \
    --tag ${REGISTRY}/${APP_NAME}:latest \
    -f Dockerfile .

echo "Build completed: ${REGISTRY}/${APP_NAME}:${VERSION}"
Enter fullscreen mode Exit fullscreen mode

This script ensures every production image is tagged with a version, passes a security scan, and passes all tests. It turns a complex series of commands into a single, reliable step.

Let’s not forget about the application’s behavior inside the container. The HEALTHCHECK instruction in the Dockerfile is vital. It tells the container platform (like Docker Swarm or Kubernetes) how to check if my app is alive and ready. My /health endpoint returns a simple JSON status. Without this, your orchestrator won’t know if your app is stuck starting up or has crashed.

Resource limits are another practical consideration. While not in the Dockerfile itself, when you run or deploy the container, you should set limits. A Go application’s garbage collector can be tuned, but it works better if it knows its constraints. If you tell Kubernetes the container needs 100Mi of memory, the Go runtime can manage its heap more efficiently.

Configuration should always come from outside the container. My app reads the PORT from an environment variable. For secrets like API keys, I use the secret management system built into my orchestrator, never baking them into the image or passing them via build arguments.

Monitoring is built right into the sample application with the /metrics endpoint. It exposes Go runtime metrics in a format that Prometheus can scrape. When this container runs in Kubernetes, I can set up a ServiceMonitor to collect these metrics automatically. All application logs go to standard output (log.Printf), where they can be collected by tools like Fluentd or Loki.

Finally, let’s validate that our container works as expected with some integration tests.

package main

import (
    "net/http"
    "testing"
    "time"
)

func TestContainerHealth(t *testing.T) {
    go func() {
        main()
    }()

    time.Sleep(100 * time.Millisecond)

    resp, err := http.Get("http://localhost:8080/health")
    if err != nil {
        t.Fatalf("Health check failed: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        t.Errorf("Expected status 200, got %d", resp.StatusCode)
    }
}
Enter fullscreen mode Exit fullscreen mode

These tests can be run inside the container during the build process to ensure the packaged application responds correctly.

To sum it up, building a production-ready container is about intentional choices. Use multi-stage builds to separate the build environment from the runtime environment. Start from scratch or a minimal base like alpine. Always run as a non-root user. Inject version information at build time. Set up health checks and expose metrics. Automate security scanning and testing.

The outcome is a container that is small, fast, and secure. It uses resources predictably and fits perfectly into modern, automated deployment pipelines. It gives you confidence that what runs on your laptop will run the same way in the cloud, with no surprises. This approach has served me well across countless projects, and it’s a foundation you can build upon for any Go service.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)