Dockerfile for Gin: Local Development and Other Stages
11 min read

Dockerfile for Gin: Local Development and Other Stages

Docker is often immediately associated with production, even though the needs of each stage in a Golang + Gin application’s lifecycle differ greatly. Local development needs fast iteration, testing needs a consistent environment, while production needs a small, secure image. The common mistake is using the same Dockerfile for all these stages — the result is slow local development due to unnecessary image size, large production images carrying development tools, and an inefficient team workflow. This article discusses how to structure the right Dockerfile for each stage, from local dev to production with multi-stage builds.

Basic Principles of a Gin Dockerfile

Fundamentally, a Dockerfile for a Gin application does four things: defines the Go base image, downloads dependencies, builds the binary, then runs the application. Here’s the most basic example covering all four:

FROM golang:1.21

WORKDIR /app

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

COPY . .
RUN go build -o app

EXPOSE 8080
CMD ["./app"]

This Dockerfile is valid and runs directly, but isn’t ideal if used for every need. The golang:1.21 base image carries the entire Go toolchain (compiler, standard library source, and supporting tools) which can reach hundreds of megabytes — useful during development but wasteful at production runtime. The problem isn’t the Dockerfile code itself, but the assumption that one configuration can satisfy all needs at once.

Each stage in the application lifecycle has different priorities, and those priorities often conflict. Local development wants the fastest possible iteration, while production wants the smallest, safest image possible — two goals that, if forced into one Dockerfile, produce a bad compromise for both. The following table summarizes these priority differences before going into the details of each stage:

StageMain PriorityWhat to Avoid
Local DevFast iteration, hot reloadRebuilding the image on every code change
TestingEnvironment consistencyServers running during tests
StagingClose to production conditionsConfigurations deviating far from prod
ProductionSmall size, securityUnnecessary development tools

The next sections discuss how the basic Dockerfile above evolves into a version suited to each stage, starting with local development.

The Go version in the examples (1.21) follows the original article — adjust it to the version used in your project. The principles discussed in this article apply to any Go version, including the latest ones.

Dockerfile for Local Development

The Goal of Local Development

At the local development stage, the main focus isn’t image size or runtime security, but iteration speed. Developers want code changes to take effect immediately without rebuilding the image every time they save a file. Its three main needs:

  • Code changes visible quickly
  • No image rebuild on every save
  • Hot reload support

The Right Approach

To achieve that iteration speed, combine three approaches: a volume mount so file changes on the host are read directly by the container, a hot reload tool like air that automatically restarts the application when files change, and an image that doesn’t need size optimization since it’s only used locally.

Example Local Dev Dockerfile

FROM golang:1.21

WORKDIR /app

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

COPY . .

RUN go install github.com/air-verse/air@latest

CMD ["air"]

This Dockerfile is deliberately simple and doesn’t chase small image size. air monitors Go file changes inside the container — combine it with a volume mount (discussed further in the Docker Compose section) so code changes in the editor are detected without any image rebuild at all.

How air works is quite simple: it reads a configuration (usually from an .air.toml file), watches the specified file extensions (.go by default), then re-runs go build and restarts the process whenever a change is detected. Since the build process runs inside the container, you don’t need Go installed on the host machine at all — the entire toolchain is in the container, the host only provides the editor and source code.

# .air.toml — minimal configuration for hot reload
root = "."
tmp_dir = "tmp"

[build]
  cmd = "go build -o ./tmp/main ."
  bin = "tmp/main"
  include_ext = ["go"]
  exclude_dir = ["tmp", "vendor"]

[log]
  time = false
Hot reload is only useful if the source code is actually mounted from the host. Without a volume mount, COPY . . in the Dockerfile only copies a code snapshot at build time — air will run, but won’t detect any changes.

One other thing often missed at this stage is .dockerignore. Without this file, the COPY . . instruction copies the entire project directory contents into the image — including the .git folder, local .env files, and binaries from previous builds. Besides slowing down the build process due to the large context size, this also risks carrying sensitive data into the image.

# .dockerignore
.git
.env
tmp/
*.log

Dockerfile for the Testing Stage

For testing — both unit and integration tests — the needs differ again. Here you want a consistent environment between developers and CI, tests that run automatically when the container starts, and no server running at all because the goal isn’t serving requests.

Example Testing Dockerfile

FROM golang:1.21

WORKDIR /app

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

COPY . .

CMD ["go", "test", "./..."]

Its structure is almost identical to the local dev Dockerfile — the difference is only in the CMD. Instead of running air or the application binary, the container directly runs go test ./... which executes the entire project test suite then exits. Dockerfiles like this usually aren’t run manually by developers, but are called automatically in CI pipelines on every push or pull request.

The reason tests run inside a container rather than directly on the CI runner machine is consistency. The Go version on developer A’s laptop can differ from developer B’s laptop, and both can differ again from the version installed on the CI runner. Tests that pass in one environment but fail in another — commonly called “works on my machine” — are a classic frustration source that can be avoided by running tests inside an image whose Go version is explicitly pinned.

# Variation: testing with a coverage report
FROM golang:1.21

WORKDIR /app

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

COPY . .

CMD ["go", "test", "-v", "-cover", "./..."]

The -cover flag above adds code coverage information to the test output, and -v shows details of every test case run — both are optional, but often used in CI pipelines to monitor test quality continuously.


Dockerfile for Production (Multi-Stage Build)

Why Multi-Stage Builds?

If the development Dockerfile were used directly in production, three problems appear at once. The image becomes large because it still carries the entire Go toolchain. Many tools like air get carried along even though they’re never used at runtime. The attack surface also widens — the more binaries and tools inside the image, the more potential security holes that must be maintained.

The solution to all three problems is a multi-stage build: the build process and runtime process are split into two separate stages, and only the final result (the binary) is carried into the final image.

flowchart TD
    A[Stage: builder<br/>golang:1.21-alpine] --> B[go mod download]
    B --> C[go build -o app]
    C --> D[Binary app produced]
    D -.->|COPY --from=builder| E[Stage: runtime<br/>alpine:3.19]
    E --> F[Final image: only binary + alpine]

Example Production Dockerfile

# Build stage
FROM golang:1.21-alpine AS builder

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

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app

# Runtime stage
FROM alpine:3.19

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

EXPOSE 8080
CMD ["./app"]

The first stage (builder) uses golang:1.21-alpine — the Alpine variant of the Go image, lighter than the default image — to compile the binary. CGO_ENABLED=0 ensures the binary is compiled statically without dependencies on the system C library, so it can run directly on a minimal base image like Alpine without a “shared library not found” error.

The second stage (runtime) starts from a clean alpine:3.19 image, then only copies one file: the build result binary from the builder stage via the COPY --from=builder instruction. No source code, no Go toolchain, no dependency manager — just the binary ready to run.

The results of this approach:

  • A much smaller image than a single-stage Dockerfile
  • No Go compiler carried into the runtime
  • Safer because the attack surface is smaller, and faster when pulled or deployed

CGO_ENABLED=0 here isn’t optional. Without this flag, the binary usually stays dynamically linked to glibc, and will fail to run on Alpine which uses musl libc — the error usually appears as a confusing “no such file or directory” because the binary actually exists, only its dynamic linker isn’t found. Compiling statically (CGO_ENABLED=0) eliminates that dependency entirely.

As an alternative to alpine:3.19, the scratch base image or gcr.io/distroless/static can also be used for the runtime stage. Both are even more minimal than Alpine — scratch is truly empty with no shell or package manager at all. The trade-off is that debugging becomes harder because there’s no shell to get into the container (docker exec won’t work without a shell), so Alpine is often the more practical middle choice: small enough, but still has sh for emergency debugging.


Dockerfile for Staging

Staging is often treated as if it were the same as testing or even local dev, even though its purpose is different. Staging exists to validate that the application runs correctly in conditions as close as possible to production — including the image used, resource size, and environment variable configuration. If staging uses a Dockerfile that differs greatly from production, bugs that appear in production might never appear in staging, and staging loses its function as the last validation stage before release.

The safest approach for staging is using the same multi-stage Dockerfile as production, with differences only in environment variable values or the image tag used at deploy time:

# Dockerfile.staging — identical to production,
# the difference is in the environment variables at runtime
FROM golang:1.21-alpine AS builder

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

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app

FROM alpine:3.19

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

ENV APP_ENV=staging

EXPOSE 8080
CMD ["./app"]

The only addition from the production version is ENV APP_ENV=staging, which the application reads to determine which configuration to use — for example connecting to the staging database, not the production database. By keeping the Dockerfile structure identical between staging and production, you ensure that what’s validated in staging truly represents what will happen after release to production.


Combining with Docker Compose

At the project level, each stage is usually paired with a different Compose file, so each environment’s configuration stays separate and doesn’t mix:

  • docker-compose.dev.yml
  • docker-compose.test.yml
  • docker-compose.prod.yml

Here’s an example configuration for local dev, leveraging the volume mount so the hot reload from the earlier section actually works:

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    volumes:
      - .:/app
    ports:
      - "8080:8080"

The volumes: - .:/app line is what connects the host code with the container code in real time. As soon as a file changes on the host, air inside the container detects it and restarts the application. This approach maintains separation of concerns between environments — dev configuration will never leak into prod, and vice versa.

For staging and production, the Compose files are much simpler because there’s no volume mount to maintain — the image already contains the final binary, so there’s no reason to mount source code from the host:

# docker-compose.prod.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - APP_ENV=production
    restart: unless-stopped

Note there’s no volumes section in the production configuration — this is intentional. Production must run exactly what was built into the image, without any chance of host files accidentally overwriting the container’s contents. restart: unless-stopped is also an important addition in production, so the container automatically comes back up if it crashes or the host is restarted — something usually unnecessary during local dev.


Common Mistakes to Avoid

✗ Using one Dockerfile for all stages (dev, test, staging, production)
✗ Optimizing the dev Dockerfile like production (multi-stage, minimal layers)
✗ Using the "latest" tag as a base image
✗ Running hot reload tools (like air) in production

These four mistakes share the same root: mixing the needs of different stages into one configuration. Here’s additional context for each:

One Dockerfile for all stages usually starts from good intentions — “keep it simple, one Dockerfile is enough”. The result is a Dockerfile that’s a suboptimal compromise for any stage: too heavy for local dev, too risky for production.

Optimizing the dev Dockerfile like production is the opposite — developers apply multi-stage builds even for local dev, when multi-stage builds mean every code change requires a full rebuild from scratch. This actually eliminates the hot reload benefit that should be gained at this stage.

The latest tag looks practical because it always gets the newest version, but it actually makes builds non-reproducible. An image that builds successfully today could fail to build tomorrow if the base image maintainer releases a new version that changes behavior unexpectedly. Always pin versions explicitly, for example golang:1.21-alpine not golang:latest.

Hot reload in production like air adds a watcher process running continuously in the background, monitoring the filesystem for changes that will never happen in production — source code in production images is immutable. This watcher process just wastes CPU and memory resources with zero benefit.


Summary

  • Local dev prioritizes speed — use volume mounts and hot reload tools like air, don’t optimize image size.
  • Testing prioritizes consistency — run go test ./... inside a container whose environment matches CI.
  • Staging should be close to production — use multi-stage builds so staging behavior mirrors production.
  • Production prioritizes size and security — multi-stage builds with a minimal base image like Alpine, and CGO_ENABLED=0 for a static binary.
  • Separate the Dockerfile per stage (Dockerfile.dev, Dockerfile.test, etc.) and pair each with the appropriate Docker Compose file.
  • Avoid latest-tagged base images so builds stay reproducible.
  • Don’t carry development tools (like air) into production images — that enlarges the attack surface with no runtime benefit.
  • Always use .dockerignore so COPY . . doesn’t also copy .git, .env, or other sensitive files into the image.

Portfolio