The push-button deployment myth
You finish a Go web server. It runs locally. You open a browser, hit localhost, and see your response. You want it online. You hear that Heroku deployments are just a single git push. You run the command. The build fails. The dyno crashes. The dashboard shows a red error.
The problem is not Heroku. The problem is the assumption that Go works like Python or Node. Those languages ship source code and rely on a runtime interpreter. Go ships compiled binaries. Heroku does not know how to compile your code unless you tell it exactly what to do. You need a buildpack to translate source into a runnable artifact, a Procfile to tell the platform how to start it, and a module definition so the compiler knows where your dependencies live.
Deployment is not magic. It is a pipeline. Source code enters one end. A static binary exits the other. The platform runs that binary. Understanding the pipeline removes the guesswork.
How Heroku actually runs your code
Heroku uses a buildpack system. A buildpack is a collection of scripts that run during the build phase. It fetches your source, installs the correct toolchain, compiles your code, and packages everything into a slug. A slug is a compressed filesystem snapshot. Heroku unpacks the slug onto a dyno, which is a lightweight virtual machine. The dyno starts the process defined in your Procfile.
Think of the buildpack as a factory floor. You drop raw materials on the conveyor belt. The factory installs the right machines, assembles the product, boxes it, and ships it to the warehouse. The dyno is the warehouse worker who opens the box and turns on the machine. Go's factory floor is straightforward because the language compiles to a single static binary. No runtime installation. No dependency hell at startup. Just one executable file.
The Go buildpack handles the heavy lifting. It reads your go.mod file, downloads dependencies, runs go build, and places the resulting binary in a known location. It also sets environment variables like CGO_ENABLED=0 to force pure Go compilation. This guarantees the binary runs on Heroku's Alpine Linux containers without needing system-level C libraries.
Buildpacks run before your code ever executes. They are the bridge between your repository and the running process. Treat them as part of your deployment contract.
The minimal setup
You need three files to deploy a Go app to Heroku. The module definition, the entry point, and the process file.
Here is the module definition that tells the compiler where your code lives:
module example.com/myapp
go 1.22
require (
// standard library only, no external deps for this example
)
Here is the entry point that starts an HTTP server:
package main
import (
"fmt"
"log"
"net/http"
"os"
)
// main starts the HTTP server on the port provided by the platform.
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// Heroku expects the server to listen on the PORT environment variable.
addr := ":" + port
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Go app running on Heroku")
})
// log.Fatal exits the process if the server fails to start.
log.Fatal(http.ListenAndServe(addr, nil))
}
Here is the process file that tells Heroku how to run the binary:
web: go run main.go
The Procfile uses a simple type: command format. The web process type tells Heroku to route HTTP traffic to this command. The go run main.go command compiles and executes your code in one step. It works for testing, but it recompiles on every dyno restart. Production deployments use a precompiled binary instead.
Procfiles are plain text. They live in the project root. They define the process model. One line per process type. No extra configuration needed.
What happens when you push
When you run git push heroku main, your local repository sends the commit history to Heroku's remote. Heroku receives the payload and spins up an isolated build container. The container clones your code and runs the Go buildpack.
The buildpack checks for go.mod. If it exists, the buildpack downloads the Go toolchain matching your module version. It runs go mod download to fetch dependencies. It runs go build -o app . to compile your code. The -o app flag names the output binary. The buildpack then creates the slug, which includes the binary, your source code, and any static assets.
Once the slug is ready, Heroku provisions a dyno. The dyno unpacks the slug and executes the command from your Procfile. The process starts. Heroku monitors the stdout and stderr streams. If the process exits, the dyno restarts it. If it crashes repeatedly, the dyno goes to sleep and logs the failure.
The entire pipeline runs in under a minute for small projects. The build phase is deterministic. The run phase is stateless. Your code does not modify the slug filesystem. It reads configuration from environment variables and writes logs to stdout. This matches the twelve-factor methodology that Heroku enforces.
Deployment is a state machine. Source becomes binary. Binary becomes process. Process becomes traffic. Keep each transition explicit.
Production-ready deployment
The minimal setup works for prototypes. Production apps need a few adjustments. You want a precompiled binary in the Procfile. You want environment variables for configuration. You want graceful shutdowns.
Here is a production-ready Procfile:
web: ./app
The ./app command runs the binary that the buildpack already compiled. It skips the go run step. Startup is faster. Memory usage is lower. The binary is already linked and ready to execute.
Here is how you pass configuration to the running process:
heroku config:set PORT=8080
heroku config:set DATABASE_URL=postgres://user:pass@host/db
heroku config:set LOG_LEVEL=info
Environment variables are injected into the dyno's process environment before your code starts. Your Go program reads them with os.Getenv. The twelve-factor rule states that config lives in the environment, not in code. This keeps deployments immutable. You change the config, not the binary.
Here is a realistic HTTP handler that respects context and logs errors:
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"time"
)
// handleHealth checks environment configuration and returns a status response.
func handleHealth(w http.ResponseWriter, r *http.Request) {
// Context carries the request lifecycle and cancellation signals.
ctx := r.Context()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
// Return 503 when required config is missing.
http.Error(w, "missing DATABASE_URL", http.StatusServiceUnavailable)
return
}
// Simulate a short check with a timeout.
select {
case <-ctx.Done():
http.Error(w, "request cancelled", http.StatusGatewayTimeout)
case <-time.After(500 * time.Millisecond):
fmt.Fprintln(w, "ok")
}
}
The handler uses r.Context() as the first source of lifecycle information. It checks configuration early. It returns explicit HTTP status codes. It respects cancellation. This pattern scales to larger services.
Context is plumbing. Run it through every long-lived call site.
Where things go wrong
Deployment failures usually fall into three categories. Missing module definitions, incorrect Procfile syntax, and environment mismatches.
The Go compiler requires module mode for modern projects. If you forget go.mod, the buildpack fails with go: cannot determine module path for source directory. The compiler expects a module root. Create the file with go mod init example.com/yourapp. Commit it. Push again.
Procfile syntax is strict. A trailing space or wrong process type breaks routing. If you write web:go run main.go without a space after the colon, Heroku treats the whole line as an unknown process type. The dyno starts but receives no traffic. The compiler does not catch this. Heroku's build logs will show Procfile syntax error. Fix the spacing. Restart the dyno.
Environment variables are the most common runtime failure. Your code expects DATABASE_URL. Heroku does not set it by default. The process starts, hits the missing variable, and panics or returns 503. The compiler cannot check runtime configuration. You must validate config at startup. Return clear errors. Log the missing keys. Do not guess.
The worst goroutine bug is the one that never logs. Validate configuration early. Fail fast. Restart cleanly.
Choosing your deployment path
Use Heroku when you want zero infrastructure management and are comfortable paying for convenience. Use Docker when you need full control over the runtime environment and want to deploy to any cloud provider. Use a raw VPS when you want to minimize costs and are willing to manage SSH, systemd, and firewall rules yourself. Use a managed PaaS like Render or Fly.io when you want Heroku-like simplicity with better pricing or edge deployment options. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Deployment is a tradeoff. Pick the tool that matches your team's operational bandwidth. Do not overengineer the pipeline before you have traffic.