Deploying a Go service without writing a Dockerfile
The standard way to deploy a Go service is a multi-stage Dockerfile: a builder stage with the toolchain, a `go build`, then a copy of the resulting binary into `scratch` or `distroless`. It's about twenty lines and everyone has written it a dozen times.
It's also worth asking what those twenty lines are doing. Go's whole deployment story is that `go build` produces one static binary with no runtime, no interpreter and no dependency tree on the target. The multi-stage Dockerfile exists to get that binary into an image, and the image exists because the platform wanted an image. The binary was always the artefact.
What the alternative looks like
Clone the repo on the target, run `go build`, run the binary. That's it, and if the platform detects `go.mod` it can infer all three steps without configuration.
# A Go repo with a go.mod: detected, built, and started with no config
pandastack apps create --name checkout-api \
--git-url https://github.com/acme/checkout
# Explicit, if your main package is not at the repo root
pandastack apps create --name checkout-api \
--git-url https://github.com/acme/checkout \
--build-cmd 'go build -o bin/server ./cmd/server' \
--start-cmd './bin/server'The runtime version comes from the `go` directive in your `go.mod`, the same file that already governs your local build. There's no second place to state the version and no way for the two to drift.
module github.com/acme/checkout
go 1.25.7 // the toolchain version the build usesThe isolation question
The obvious objection: doesn't skipping the container mean losing isolation? It depends entirely on what's underneath. If 'no Docker' means your process runs next to other tenants' processes on a shared kernel, that's a real downgrade.
In our case each app gets its own Firecracker microVM — a separate kernel, separate memory, separate virtual devices. That's a stronger boundary than a container namespace, not a weaker one. The container was never providing the isolation; the host's namespacing was, and a VM boundary is what container-based platforms use underneath anyway once multiple tenants are involved.
cgo is where 'static' gets complicated
'Go produces a static binary' is true until you import something that uses cgo, at which point you're dynamically linking against the system C library and the deployment story changes.
The two you're most likely to hit without realising:
- `net` and `os/user` use cgo-based resolvers by default on some platforms, which is why a binary built on one distro can behave differently on another for DNS.
- SQLite via `mattn/go-sqlite3`, plus most image, crypto and machine-learning bindings, genuinely require a C compiler at build time.
# Fully static, pure Go resolver — the safest thing to deploy anywhere
CGO_ENABLED=0 go build -ldflags='-s -w' -o bin/server ./cmd/server
# Needs cgo (SQLite, image codecs): the build host must have a C toolchain
CGO_ENABLED=1 go build -o bin/server ./cmd/server
# Pure-Go SQLite avoids the whole question
// import _ "modernc.org/sqlite" instead of mattn/go-sqlite3Default to `CGO_ENABLED=0`. It's smaller, faster to build, and behaves identically everywhere. If you need cgo, check that your build environment actually has a C toolchain — ours does, which is why cgo-dependent services build without extra setup, but it's worth confirming rather than assuming on any platform.
Use embed and ship one file
If your service serves static assets, templates or migrations, `embed` puts them inside the binary. That eliminates an entire category of deploy bug where the binary and its assets get separated, or where a relative path resolves differently because the working directory isn't what you expected.
import (
"embed"
"net/http"
)
//go:embed static/* templates/*
var assets embed.FS
func main() {
http.Handle("/static/", http.FileServer(http.FS(assets)))
// migrations too — no separate directory to keep in sync with the binary
}A Go service that embeds its assets is genuinely one file. Whatever your deployment mechanism, fewer moving parts is fewer things that can arrive in the wrong order.
Handle SIGTERM or your deploys drop requests
This is the one thing that matters more than everything else here, and it's the one most commonly skipped. A blue-green deploy sends SIGTERM to the old version. A server that doesn't handle it dies instantly, and every in-flight request is severed — no response, no error, just a closed connection that the client reports as a network failure.
func main() {
srv := &http.Server{Addr: ":" + port(), Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
<-stop
// Finish in-flight requests, refuse new ones, then exit.
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("forced shutdown: %v", err)
}
}Twelve lines, and it converts 'every deploy drops some requests' into 'deploys are invisible'. Go's standard library gives you this directly; there's no excuse for not wiring it up.
Read the port, and bind to the right address
Two small things that cause disproportionate confusion.
func port() string {
if p := os.Getenv("PORT"); p != "" { return p }
return "8080"
}
// Bind all interfaces. "127.0.0.1:8080" is reachable only from inside the
// machine, so the platform's health check fails and the app never goes live.
srv := &http.Server{Addr: ":" + port()}Binding to localhost is a genuinely common cause of 'my app built fine but the health check never passes'. From the app's perspective everything is working; from outside there's nothing listening on a reachable address. We changed our health probe to check the app's actual network address rather than localhost specifically so this failure is honest rather than falsely green.
When you should still use a container
This isn't an argument that images are obsolete. There are cases where you want one.
- Your service needs specific system packages — ffmpeg, a particular libvips, a proprietary driver. A Dockerfile is the clearest way to declare that.
- You need the exact same artefact promoted through staging to production with a verifiable digest. Building from source per environment gives you the same source, not the same bytes.
- You're deploying to Kubernetes, where the image is the unit the whole ecosystem is built around.
- Compliance requires an SBOM and signed images. That tooling assumes an image exists.
But for the ordinary case — a Go HTTP service, static binary, talking to Postgres — the Dockerfile is ceremony around a step Go already handles. `go build` produced the deployable artefact before the container did anything, and skipping the wrapper removes a file to maintain, a registry to push through, and a layer-cache to reason about.
Frequently asked questions
Can I deploy a Go app without writing a Dockerfile?
Yes. Go compiles to a single self-contained binary with no runtime to install on the target, so a deploy only needs three steps: clone the repository, run go build, run the resulting binary. A platform that detects go.mod can infer all three without configuration, and the toolchain version comes from the go directive already in that file — so there is no second place to state it and no way for the two to drift apart.
Do I lose isolation by not using a container?
That depends entirely on what runs underneath. If skipping the container means your process shares a kernel with other tenants, that is a real downgrade. If each app gets its own microVM, the boundary is a separate kernel with separate memory and virtual devices, which is stronger than a container namespace rather than weaker. Container platforms serving multiple tenants generally put a VM boundary underneath anyway — the container was never the thing providing tenant isolation.
Does CGO_ENABLED matter when deploying Go?
Yes, more than most people expect. With CGO_ENABLED=0 you get a fully static binary using Go's pure-Go DNS resolver, which behaves identically on any Linux and is the safest default. With cgo enabled you dynamically link against the system C library and your build host needs a C toolchain. Packages like mattn/go-sqlite3 and most image or machine-learning bindings require it. If you can avoid cgo — for instance by using modernc.org/sqlite instead — the deployment story gets simpler in every respect.
Why does my Go app pass its build but fail its health check?
The most common cause is binding to 127.0.0.1 instead of all interfaces. A server listening on localhost is reachable only from inside its own machine, so anything probing from outside finds nothing listening even though the process is running perfectly. Use ":"+port rather than "127.0.0.1:"+port. The second most common cause is ignoring the PORT environment variable and hardcoding a port the platform is not routing to.
How do I stop deploys from dropping in-flight requests in Go?
Handle SIGTERM and call srv.Shutdown with a timeout context. A blue-green deploy signals the old version to stop; a server that does not handle the signal dies instantly and every in-flight request is severed with no response — clients see a network failure rather than an error. The standard library handles this in about a dozen lines: listen for SIGTERM, then call Shutdown, which stops accepting new connections and waits for existing handlers to finish before returning. It is the single highest-value thing on this list.
Keep reading
- App hosting on PandaStack — go.mod detected, built, and started with no configuration
- Deploying FastAPI without a Dockerfile
- Firecracker vs Docker — what the isolation boundary actually is
- Sandboxing an untrusted Go build
- Deploying one service out of a monorepo
49ms p50 cold start. Fork, snapshot, and scale to zero.