Fast Rebuilds Without Root: A Hardened Go Service with Docker Compose

At 3:00 AM, your pager screams. A developer merged a one-line handler fix, but the CI pipeline has been churning for twelve minutes reinstalling the world. Every rebuild downloads dependencies, recompiles unchanged packages, and ships a bloated image with the entire Go toolchain still attached. By the time the deployment lands, the incident has escalated.

The immediate pain is slow feedback. The architectural mistake is treating source code, dependency caches, build tools, and runtime state as one trust boundary. Most container setups collapse these layers into a single artifact that runs as root, writes to its own filesystem, and carries debugging tools into production.

Docker Compose v2 provides the application model; BuildKit handles the build graph. Neither automatically creates that separation. Here is a small, runnable deployment that makes it explicit.

Separate the build machine from the running application

As an external adopter of docker/compose, I treat Compose as a declarative interface to one Docker host, not as a miniature cluster scheduler.

The architecture has three boundaries:

  • BuildKit owns dependency and compiler caches.
  • A disposable builder stage owns the Go toolchain.
  • The runtime receives one binary, an unprivileged identity, and a read-only filesystem.

The service publishes only on loopback. An internal bridge prevents ordinary outbound connectivity through that network; it does not replace host firewall policy or protect against a compromised Docker daemon.

The following single shell block creates the complete project. It requires Docker Compose v2 and a working BuildKit-backed builder. Run it in an empty directory.

cat > go.mod <<'EOF' module example.com/compose-service go 1.26 EOF cat > main.go <<'EOF' package main import ( "fmt" "net/http" "os" "time" ) func main() { if len(os.Args) > 1 && os.Args[1] == "-health" { client := &http.Client{Timeout: time.Second} response, err := client.Get("http://127.0.0.1:8080/healthz") if err != nil { os.Exit(1) } response.Body.Close() if response.StatusCode != http.StatusOK { os.Exit(1) } return } mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Compose service is ready") }) server := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } if err := server.ListenAndServe(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } EOF cat > Dockerfile <<'EOF' # syntax=docker/dockerfile:1 FROM golang:1.26-bookworm AS build WORKDIR /src COPY go.mod ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY main.go ./ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \ -o /out/service . FROM scratch COPY --from=build /out/service /service USER 65532:65532 EXPOSE 8080 ENTRYPOINT ["/service"] EOF cat > .dockerignore <<'EOF' .git .env node_modules dist EOF cat > docker-compose.yml <<'EOF' services: api: build: context: . image: compose-service:local init: true user: "65532:65532" read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true cpus: "0.50" mem_limit: 128m pids_limit: 64 restart: unless-stopped stop_grace_period: 15s ports: - "127.0.0.1:8080:8080" healthcheck: test: ["CMD", "/service", "-health"] interval: 10s timeout: 2s retries: 3 start_period: 5s logging: driver: json-file options: max-size: "10m" max-file: "3" networks: - backend networks: backend: driver: bridge internal: true EOF docker compose config --quiet docker compose up --build --wait --wait-timeout 60 curl --fail http://127.0.0.1:8080/ 

Why the second build behaves differently

Changing main.go invalidates the compilation layer, but not the earlier dependency layer. The compiler cache mount also survives that invalidation on the same builder, allowing Go to reuse unchanged package artifacts.

This example deliberately has no external modules. When adding dependencies, commit go.sum and change the manifest copy to COPY go.mod go.sum ./. Otherwise, the example would demonstrate a cache without demonstrating its correct invalidation boundary.

The corresponding Node.js failure pattern is expensive: COPY . . precedes npm ci, so every source edit invalidates the dependency layer. Copying host node_modules into the build context adds transfer overhead and potentially incompatible native binaries. The root cause is the dependency graph, not Docker being inherently slow.

Cache mounts are acceleration, not durable storage. A fresh CI builder still starts cold unless cache persistence is configured deliberately. Shared caches also need trust boundaries; do not give untrusted pull-request builds unrestricted access to release-build infrastructure.

Hardening without claiming a sandbox miracle

A dangerous incident pattern starts with application code execution inside a root container and ends with host files modified through a writable bind mount. That is exposed host access, not necessarily a kernel escape. Actual container escapes generally require another weakness, such as a vulnerable kernel or unsafe runtime configuration.

Here, there are no host mounts, no Docker socket, no retained capabilities, and no writable application filesystem. Running as UID 65532 reduces privileges further. These controls limit consequences; they do not make shared-kernel containers an adequate boundary for hostile tenants.

What this configuration does not promise

The healthcheck proves that the HTTP server responds. It does not prove business correctness. Compose waits for health during startup, but an unhealthy status alone does not trigger the restart policy; that policy reacts to process termination.

The resource limits are local container constraints, not capacity reservations. Half a CPU can increase latency under load, and a 128 MiB limit can produce an OOM kill rather than graceful degradation.

Finally, scratch removes shell-based debugging and omits CA certificates. That fits this dependency-free, internal service, but an HTTPS client needs a trust store. Pin base-image digests in release builds and update them intentionally.

The useful outcome is not merely a smaller image. It is a build pipeline where source edits stay cheap and runtime privileges remain independent of whatever the compiler needed.

The hardest operational dilemma here is the tradeoff between developer velocity and production hardening. A scratch base with no shell makes post-deployment debugging painful. You cannot docker exec into a running container to inspect state or tail logs. You cannot install strace or tcpdump when a network issue surfaces at 2:00 AM. The alternative—shipping a full base image with debugging tools—reintroduces the attack surface you just eliminated and bloats the artifact you optimized.

Where do you draw the line? Do you maintain separate debug and release builds, accepting the operational complexity of managing two artifacts? Do you accept the scratch constraint and rely entirely on structured logging and external observability? Have you ever had to choose between a 3 MB production image and your ability to debug a live incident? What does that decision process look like in your organization?