Gin + Docker Compose for Local Development
10 min read

Gin + Docker Compose for Local Development

Setting up a consistent local environment for every engineer is a classic challenge in Golang + Gin backend development. Without containerization, the problems that often arise include different Go versions between machines, mismatched database dependencies, unsynchronized environment configurations, all the way to the classic “works on my machine” problem — code that runs smoothly on one developer’s laptop but errors on another developer’s laptop. Docker Compose answers this problem with a simple, explicit, and easily scalable approach, because the entire service definition — API, database, cache, and other dependencies — is written in one file that can be run with a single command.

Why Docker Compose for Local Development?

Docker Compose provides four main benefits for local development. First, you can run the API along with all its dependencies (database, cache, and others) with just one command, without needing to install and configure each one manually. Second, the environment is exactly the same for all developers on the team, because the definition is explicitly written in the Compose file, not dependent on what’s installed on each machine. Third, local and production configurations are clearly separated — the dev Compose file will never mix with the configuration used in production. Fourth, repeated manual setup on new machines is no longer needed.

The practical impact is felt most clearly when onboarding new engineers. Without Docker Compose, a new engineer usually needs to install a specific Go version, set up local PostgreSQL, configure environment variables one by one, and often ends up with an environment slightly different from their teammates’. With Docker Compose, the entire process shrinks to a single docker compose up command.


Example Project Structure

Before writing the Compose configuration, it’s good to align on the project structure first. Here’s a simple structure for a Gin project used as the reference throughout this article:

.
├── docker-compose.yml
├── Dockerfile
├── .env
├── go.mod
├── go.sum
├── main.go
└── internal/
    ├── handler
    ├── service
    └── repository

This structure separates internal/handler for the HTTP layer, internal/service for business logic, and internal/repository for data access — a layer separation commonly used in medium-scale Gin projects. docker-compose.yml and Dockerfile are placed at the project root so the build context covers all source code without extra path configuration.

The .env file at the root stores the environment variables the application uses at runtime — for example the database connection string, secret keys, or other configuration flags. Separating .env from the application code has two benefits: first, credentials don’t get committed to version control (as long as .env is in .gitignore); second, environment variable values can differ between local, staging, and production without changing a single line of code, because the application just reads from the environment available when the container runs.


Dockerfile for the Gin Application

For local development, the needed Dockerfile is quite simple — no multi-stage builds or image size optimization required, because this image is only used locally on developers’ machines.

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"]

Several things to note about this Dockerfile:

  • The base image uses the official Go image (golang:1.21), not the lighter Alpine variant, because image size isn’t a priority at this stage
  • Dependencies are cached via go.mod and go.sum — both are copied and downloaded before other source code, so Docker can leverage layer caching and doesn’t need to re-download dependencies on every code change
  • The binary is built inside the container, not on the host, so the Go version used for building is always consistent with what’s defined in the image
For production, this Dockerfile is usually modified into a multi-stage build so the image size is smaller and more secure. Detailed discussion is in the separate article about Dockerfiles for various stages.

docker-compose.yml for Local Development

Here’s an example of the simplest Docker Compose configuration for running a Gin application:

version: '3.9'

services:
  api:
    build: .
    container_name: gin-api
    ports:
      - "8080:8080"
    volumes:
      - .:/app
    env_file:
      - .env
    command: ./app

Each configuration line has a specific role. build: . tells Compose to build the image from the Dockerfile in the current directory. ports: - "8080:8080" maps port 8080 in the container to port 8080 on the host, so the API can be accessed from a browser or tools like curl on the local machine. volumes: - .:/app syncs the entire project directory contents on the host to the /app directory inside the container — this line is what makes code changes in the editor immediately reflected inside the container without rebuilding the image. env_file: - .env loads environment variables from the .env file on the host, so credentials and configuration don’t need to be written directly in the Compose file.

Volumes are very important at this stage. Without the volumes line, code changes are only stored in the image from the last build — you’d have to run docker compose up --build every time you change a single line of code, which is clearly impractical for fast iteration.


Running the Application

To run the application for the first time or after changing the Dockerfile, use the --build flag so Compose rebuilds the image before running the container:

docker compose up --build

If the image was previously built and there are no changes to the Dockerfile or dependencies, you can run directly without that flag:

docker compose up

Once the container is running, the API will be available at:

http://localhost:8080

The difference between these two commands is important to understand: --build always triggers the image build process from scratch (or uses layer caches when nothing changed), while without --build, Compose directly runs the container from the previously existing image — faster, but at risk of running outdated code versions if the Dockerfile changed.

By default, docker compose up runs in the foreground — the terminal shows logs from all containers in real time, and the containers stop when you press Ctrl+C. To run in the background, add the -d (detached) flag:

docker compose up -d

When running in detached mode, you can still monitor the logs anytime with:

docker compose logs -f api

The -f (follow) flag makes logs keep flowing in real time, similar to tail -f on a regular log file. The combination of up -d and logs -f is often the daily workflow: run all services in the background once in the morning, then monitor the logs of the specific service being worked on.


Manually rebuilding every time there’s a code change is very inefficient for local development, even with a volume mount — the running ./app process won’t automatically re-read changed code. The solution is a hot reload tool like air, which monitors file changes and automatically restarts the application as soon as a file is saved.

Install air in the Dockerfile:

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

Then change the command section in Docker Compose to run air instead of the binary directly:

command: air
sequenceDiagram
    participant Editor as Editor (host)
    participant Volume as Volume Mount
    participant Air as air (container)
    participant App as Gin Application

    Editor->>Volume: Save .go file changes
    Volume->>Air: File change detected
    Air->>App: Stop old process
    Air->>App: go build + restart
    App-->>Editor: Endpoint ready to access again

With the combination of air and a volume mount, the workflow becomes: a file changes in the editor, air detects the change through the mounted volume, then automatically restarts the application with the latest code. The development workflow becomes far faster than having to run docker compose up --build repeatedly.

air reads its configuration from the .air.toml file in the project root. If this file doesn’t exist, air uses the default configuration, which is generally enough for simple projects — but it’s still recommended to create your own .air.toml to configure excluded directories (like vendor or tmp) so the watcher process doesn’t monitor irrelevant files.

Example of Adding a Database (PostgreSQL)

One of Docker Compose’s biggest advantages is the ease of adding new dependencies. For example, to add PostgreSQL as the database, just add one new service:

services:
  api:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - db
    env_file:
      - .env

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app_db
    ports:
      - "5432:5432"

The api service now has depends_on: - db, ensuring the db container starts before the api container is run. The db service itself uses the official postgres:16 image from Docker Hub, without needing a dedicated Dockerfile — the POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB environment variables are automatically read by this image to create the user and database when the container first starts.

With this pattern, adding other dependencies like Redis for caching or RabbitMQ for message queues is just a matter of adding one more service to the same Compose file — no need to change how the other services are configured.

depends_on only guarantees the container start order, not that the database is truly ready to accept connections. PostgreSQL takes a few seconds to initialize after the container starts. If the application immediately tries to connect at startup, consider adding retry logic on the application side or using a healthcheck in Compose to wait until the database is actually ready.

To handle this timing problem more explicitly, Docker Compose supports combining healthcheck and depends_on with the service_healthy condition:

services:
  api:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy
    env_file:
      - .env

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app_db
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 5s
      retries: 5

With this configuration, Compose runs pg_isready every 5 seconds to check whether PostgreSQL is ready to accept connections. The api container won’t be started until db actually reports healthy status, not just running. This approach is far more robust than a plain depends_on, especially for databases that need longer initialization time or when running several interdependent services at once.


Best Practices for Local Development

The following practices help keep the Docker Compose setup healthy and prevent problems later:

  • Use Docker Compose only for local — don’t use it directly in production without modification
  • Separate configuration files for production, both the Dockerfile and the Compose itself
  • Don’t commit the .env file to version control, because it usually contains credentials and sensitive configuration
  • Use volumes so you don’t need to rebuild constantly on every code change
  • Don’t over-optimize the Dockerfile for dev — multi-stage builds and layer minimization are production needs, not local development

The principle uniting all these points is simple: local development should be fast and comfortable, not perfect. Optimizations useful in production can actually hinder productivity if applied at the wrong stage.

Besides the five points above, there are a few small habits that also help keep the setup tidy long-term:

HabitReason
Provide .env.exampleGives a reference for which environment variables are needed, without leaking the actual values
Give services descriptive namesapi, db, cache are easier to understand than service1, service2
Clean up unused volumesdocker compose down -v removes volumes all at once, useful when wanting to reset local database state
Document the ports usedPrevents port conflicts between projects running simultaneously on the same machine

.env.example is especially important for onboarding — this file contains variable names without sensitive values (for example DB_PASSWORD= empty or with a placeholder), so new engineers know which variables need filling without guessing or asking teammates.


Summary

  • Docker Compose unifies the API and all its dependencies (database, cache, etc.) into one file that can be run with a single command.
  • Volume mounts (volumes: - .:/app) are the key to making code changes on the host immediately reflected inside the container.
  • Combine volume mounts with a hot reload tool like air so the application automatically restarts on every code change.
  • Adding new dependencies (database, cache, message queue) is just adding one new service to the same Compose file.
  • depends_on only manages the container start order, not guaranteeing the service is ready to accept requests.
  • Don’t commit the .env file, and don’t use the same Compose configuration for local and production.
  • Local development prioritizes iteration speed — avoid optimizations that are only relevant to production.

Portfolio