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.modandgo.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.
Hot Reload (Optional but Highly Recommended)
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 againWith 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.
airreads its configuration from the.air.tomlfile in the project root. If this file doesn’t exist,airuses the default configuration, which is generally enough for simple projects — but it’s still recommended to create your own.air.tomlto configure excluded directories (likevendorortmp) 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_ononly 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 ahealthcheckin 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
.envfile 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:
| Habit | Reason |
|---|---|
Provide .env.example | Gives a reference for which environment variables are needed, without leaking the actual values |
| Give services descriptive names | api, db, cache are easier to understand than service1, service2 |
| Clean up unused volumes | docker compose down -v removes volumes all at once, useful when wanting to reset local database state |
| Document the ports used | Prevents 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
airso 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_ononly manages the container start order, not guaranteeing the service is ready to accept requests.- Don’t commit the
.envfile, 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.