The loop that saves your sanity
You edit a handler, save the file, and stare at the terminal. The running container keeps serving the old code. You kill the process, rebuild the image, restart the container, and finally see your change. That loop eats minutes of your day. Hot reloading fixes this. The tool watches your files, rebuilds the binary, and restarts the process automatically. Inside Docker, the setup requires a bit more care because the container filesystem and the host filesystem need to talk to each other.
Hot reloading is a development tool. It gives you instant feedback while you code. It does not belong in production. Production deployments use static binaries and orchestration tools to manage updates. Hot reload belongs in your local workflow.
How hot reload works in Docker
Hot reloading is a loop. A watcher process monitors your source files. When a file changes, the watcher triggers a build command. The build produces a new binary. The watcher kills the old process and starts the new one. In a local environment, this is straightforward. In Docker, the watcher runs inside the container, but the files live on your host machine. You mount the host directory into the container so the watcher can see the changes. The container needs the Go toolchain to rebuild the binary on the fly.
The watcher relies on file system events. On Linux, tools use inotify. On macOS, they use kqueue. Docker containers run on a Linux kernel. If you are on macOS or Windows, file events must cross the Docker socket and the virtual file system layer. This adds latency. Watcher tools handle this by polling the filesystem as a fallback. The polling interval determines how fast the tool detects changes when events don't propagate instantly.
Minimal setup with air
The community standard for Go hot reloading is air. It is a standalone binary written in Go. It supports configuration for build commands, delays, file exclusions, and graceful restarts. CompileDaemon is an older alternative with a smaller footprint, but air has become the default choice due to its feature set and active maintenance.
Here's the Dockerfile that sets up the environment. It installs air and prepares the workspace.
FROM golang:1.21-alpine
# Alpine reduces image size; golang provides the toolchain for rebuilding
WORKDIR /app
# Install air to handle file watching and process restarts
RUN go install github.com/cosmtrek/air@latest
# Copy module files first to leverage Docker layer caching
COPY go.mod go.sum ./
# Download dependencies once during build
RUN go mod download
# Copy the rest of the source code
COPY . .
# Air runs as the main process, watching for changes
CMD ["air"]
Here's the compose file that mounts your code and exposes the port.
version: "3.8"
services:
app:
build: .
# Mount host directory so air sees file changes
volumes:
- .:/app
# Expose port for testing
ports:
- "8080:8080"
Air needs a configuration file to know what to build. Run air init in your project to generate .air.toml. Here's a minimal config.
[build]
# Build output goes to tmp to avoid permission issues
cmd = "go build -o ./tmp/main ."
# Air executes this binary after a successful build
bin = "./tmp/main"
# Watch Go files and templates
include_ext = ["go", "tpl", "tmpl", "html"]
# Skip build triggers for generated or test data
exclude_dir = ["tmp", "vendor", "testdata"]
The community convention is to run air init and tweak the result. The config lives in .air.toml at the project root. Most editors run gofmt on save. air rebuilds on save. The combination keeps your code clean and running.
What happens under the hood
When you run docker compose up, the container starts. The volume mount makes your host files appear at /app inside the container. Air launches and begins polling the filesystem. You edit a file on your host. The change propagates to the container via the volume mount. Air detects the modification. It runs the build command. If the build succeeds, Air stops the running binary and starts the new one. If the build fails, Air keeps the old binary running and prints the error. This prevents breaking your app on syntax errors.
Air waits for the file to settle before triggering a build. If you type fast and save multiple times, Air batches the changes. This avoids redundant builds. The delay is configurable. The default is usually sufficient.
Air supports graceful shutdown. By default, it sends SIGINT to the process before killing it. This allows your app to close connections and finish in-flight requests. Your main function should listen for os.Interrupt and cancel the root context. This propagates shutdown to all goroutines. The context.Context always goes as the first parameter in Go functions. Functions that take a context should respect cancellation. When air sends the signal, your context cancellation path runs.
Realistic configuration
Real projects need more than the basics. You want to cache dependencies. You want to handle build errors gracefully. You want to exclude directories that change often but don't affect the binary.
Here's a compose file that adds a cache volume for Go modules. This speeds up builds when dependencies change.
version: "3.8"
services:
app:
build: .
volumes:
- .:/app
# Mount a named volume to cache downloaded modules
# This speeds up builds when dependencies change
- go-cache:/root/go/pkg/mod
ports:
- "8080:8080"
volumes:
go-cache:
Go modules cache lives in $GOPATH/pkg/mod. Mounting this as a volume avoids re-downloading dependencies every time the container restarts. The cache persists across container lifecycles.
Here's an updated config with graceful shutdown and error handling.
[build]
# Build output goes to tmp to avoid permission issues
cmd = "go build -o ./tmp/main ."
# Air executes this binary after a successful build
bin = "./tmp/main"
# Watch Go files and templates
include_ext = ["go", "tpl", "tmpl", "html"]
# Skip build triggers for generated or test data
exclude_dir = ["tmp", "vendor", "testdata"]
# Send SIGINT to allow graceful shutdown
send_interrupt = true
# Kill delay in milliseconds if process doesn't exit
kill_delay = "500"
# Stop air on build error; false keeps old binary running
stop_on_error = false
The send_interrupt flag sends SIGINT instead of SIGKILL. This triggers your shutdown handler. The kill_delay gives the process time to exit. If the process hangs, Air kills it after the delay. The stop_on_error flag controls behavior on build failure. Setting it to false keeps the old binary running. This is useful when you are debugging and don't want the app to vanish on a typo.
Pitfalls and errors
Permission errors are common. The container might run as root, but the mounted volume has different ownership. If air cannot write the binary, the build fails with open ./tmp/main: permission denied. Create the tmp directory in the Dockerfile or ensure the volume mount permissions allow writing. The tmp directory approach avoids overwriting the running binary, which can fail on some operating systems.
File system events can be flaky on macOS and Windows. Docker uses a virtual filesystem layer. If air misses a change, it might be because the event didn't cross the boundary. air handles this by polling, but you might see a delay. If changes are not detected, check the polling interval in the config. Increasing the interval reduces CPU usage but slows detection.
Version mismatches break builds. If your go.mod specifies a newer Go version than the container provides, the compiler rejects the build with go.mod requires go 1.22, but go version is 1.21. Keep the Dockerfile Go version aligned with your project requirements. Update the base image when you upgrade Go.
Build times can grow as the project expands. go build compiles the entire binary. For large projects, the restart loop can take seconds. You can use build tags to skip heavy initialization during development. Add -tags dev to the build command and use //go:build dev in your code to exclude slow setup. This speeds up the loop without changing production behavior.
Check permissions before blaming the tool. Verify file events before tuning the watcher. Align versions before debugging build errors.
Choosing your tool
Use air when you want a feature-rich hot reload experience with configuration for build commands, delays, and file exclusions. Use CompileDaemon when you need a minimal watcher with zero configuration and a smaller binary footprint. Use nodemon when you are porting a Node.js workflow to Go and prefer a familiar tool, though it lacks Go-specific optimizations. Use manual rebuilds when you are debugging a complex race condition and need full control over the restart sequence. Use a production build without hot reload when deploying to Kubernetes or any environment where file watching is unnecessary and adds attack surface.
Hot reload belongs in development. Ship static binaries to production. Pick the tool that matches your workflow. air is the default choice for a reason.