RE:NODE

Deploy a Go web service: net/http, chi and gin in production

By RE:NODE Team
18 min read


A Go web service is the easiest thing in this category to run and the one with the fewest excuses when it misbehaves. The standard library already contains a production-grade HTTP server, the output is one statically linked binary, and an idle service sits in a few tens of megabytes. What goes wrong is almost always one of five small things: it binds to 127.0.0.1, it hard-codes the port, it ignores SIGTERM, it has no timeouts, or the garbage collector is allowed to grow past the memory limit of the container.

This post covers all five, plus the parts specific to deploying on an app server that compiles your code itself: what go.mod has to say, what happens on every start, how a GitHub deploy fits in, what the proxy sends your handlers, which databases to point at, and what to read when the console shows an error.

What a Go service needs at run time#

Almost nothing, which is the appeal. go build links your code, your dependencies and the Go runtime into one executable. There is no interpreter, no virtual environment and no node_modules on the server. The runtime brings its own scheduler and garbage collector, so a Go process is a single binary and a set of environment variables.

Two details are worth knowing before they surprise you:

  • Cgo changes the picture. With CGO_ENABLED=1 (the default when a C compiler is present) your binary may link dynamically against glibc, and packages such as mattn/go-sqlite3 need a C toolchain at build time. With CGO_ENABLED=0 the binary is fully static and has no such dependency. If a library you use has a pure Go alternative - modernc.org/sqlite instead of go-sqlite3, pgx instead of anything libpq-based - prefer it for a server you do not control.
  • Time zones and certificates. A minimal environment may lack zone data and a CA bundle. If your program calls time.LoadLocation("Europe/Berlin") or makes outbound HTTPS requests, import time/tzdata to embed the zone database. Outbound TLS needs root certificates from the system, so check an outbound call works on the first deploy rather than assuming it.

Memory is the number to plan around. A small net/http service with a database pool commonly idles between 10 and 30 MB resident and grows with concurrent requests and buffers, not with code size. That is why the smallest app tier is a sensible home for most Go services, and why the build, not the run, is what occasionally needs headroom.

net/http, chi or gin#

Start with the standard library. Since Go 1.22 the default ServeMux understands methods and path wildcards, which removed the main reason people reached for a router in the first place:

main.go
mux := http.NewServeMux()mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {    w.Write([]byte("ok"))})mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {    id := r.PathValue("id")    fmt.Fprintf(w, "user %s\n", id)})

Use chi when you want middleware composition and route groups with the standard http.Handler interface preserved. Everything written for net/http works under chi unchanged, so you lose nothing by adopting it later:

go
r := chi.NewRouter()r.Use(middleware.RequestID, middleware.Recoverer)r.Route("/api", func(r chi.Router) {    r.Get("/health", health)    r.Get("/users/{id}", getUser)})

Use gin when you want its binding and validation helpers and its ecosystem of ready-made middleware. It has its own context type, so handlers are tied to gin. In production, set GIN_MODE=release in the environment; the default debug mode prints every route at start-up and logs more than you want:

env
GIN_MODE=release

None of the three is faster in a way that matters on a small plan. The difference between them is smaller than one database round trip. Pick on how you want to write handlers and middleware, and spend the saved effort on timeouts and shutdown, which no router does for you.

go.mod, go.sum and the build on start#

On RE:NODE's Go line the server is built with go build on every start. That one sentence has consequences for how you lay out the project, so it is worth being deliberate.

First, go.mod is not decoration. It names the module, states the minimum Go version and lists every dependency with a version:

go.mod
module github.com/yourname/apigo 1.23require (    github.com/go-chi/chi/v5 v5.1.0    github.com/jackc/pgx/v5 v5.7.1)

Commit both go.mod and go.sum. The second holds checksums of every module version, and without it a build has to resolve and verify everything from scratch. Run go mod tidy before each commit so unused requirements are dropped and missing ones are added; a go.mod that disagrees with your imports is the commonest reason a build that works on your laptop fails on the server.

Second, the go line matters. If it names a version newer than the toolchain on the server, the Go tool may try to fetch the newer toolchain, or refuse to build, depending on the toolchain directive and the GOTOOLCHAIN setting. Do not set go 1.25 because that is what you happen to have installed; set the lowest version your code genuinely needs. Check the Go version the line offers before you depend on a recent language feature.

Third, every start means a compile. That is a feature - the binary always matches the source that is on the server - and a cost, because a restart is not instant. A small service builds in seconds when the build cache is warm and in a minute or more when it is cold. Treat a restart as a deploy: it takes as long as the build plus the time your service needs to open its connections.

Fourth, compile errors become start errors. If the code does not build, the server does not start, and the compiler's message is in the console. That is a better failure than a half-working service, but it means a broken commit on a branch the server follows takes the service down. Run these locally before you push:

bash
$ go mod tidy$ go vet ./...$ go test ./...$ go build -o /tmp/api .

Keep the main package where the line expects it. The simplest layout is main.go at the repository root; if you prefer cmd/api/main.go, check on the Startup tab what the Go line builds and make sure it points at your main package before the first deploy.

Ports, binding and environment variables#

There are two settings here, and both have defaults that are wrong inside a container.

The first is the bind address. localhost:8080 and 127.0.0.1:8080 listen on the loopback interface only, which nothing outside the container can reach. Use 0.0.0.0, or leave the host empty (":8080"), which listens on all interfaces.

The second is the port. Do not hard-code it. The allocated port arrives as an environment variable, and the panel's Startup tab shows the variables your server has. Read the name it shows there; the example below tries PORT first and falls back to SERVER_PORT, then to a development default, so the same binary runs on your laptop and on the server:

main.go
func port() string {    for _, name := range []string{"PORT", "SERVER_PORT"} {        if v := os.Getenv(name); v != "" {            return v        }    }    return "8080"}func requireEnv(name string) string {    v := os.Getenv(name)    if v == "" {        log.Fatalf("missing required environment variable %s", name)    }    return v}

Read every variable once, during start-up, and fail immediately if a required one is missing. A service that discovers an absent DATABASE_URL at three in the morning on a rare code path is harder to debug than one that refuses to start. The Startup tab is also where you add your own variables - database URL, session secret, API keys - so they stay out of the repository. Environment variables and secrets covers what belongs there and what does not.

A short configuration struct keeps this tidy and gives you one place to log the effective settings (minus secrets) at start:

go
type Config struct {    Addr        string    DatabaseURL string    Env         string}func load() Config {    return Config{        Addr:        ":" + port(),        DatabaseURL: requireEnv("DATABASE_URL"),        Env:         os.Getenv("APP_ENV"),    }}

Timeouts, logging and graceful shutdown#

The zero-value http.Server has no timeouts at all. That is fine on a laptop and a problem in production, because a client that opens a connection and sends nothing holds a goroutine and a file descriptor indefinitely. Use an explicit server:

main.go
srv := &http.Server{    Addr:              cfg.Addr,    Handler:           mux,    ReadHeaderTimeout: 5 * time.Second,    ReadTimeout:       15 * time.Second,    WriteTimeout:      30 * time.Second,    IdleTimeout:       60 * time.Second,    MaxHeaderBytes:    1 << 20,}
FieldWhat it limitsTypical value
ReadHeaderTimeoutTime to read request headers5 s
ReadTimeoutWhole request including body15 s
WriteTimeoutTime to write the response30 s
IdleTimeoutKeep-alive connections between requests60 s

WriteTimeout is the one that bites. It covers the whole response, so a streaming endpoint, a large download or a server-sent events stream will be cut off when it fires. For those routes, clear the deadline per request with http.NewResponseController(w).SetWriteDeadline(time.Time{}), or run them on a separate server with different limits.

Log to standard output so the panel console has it. Since Go 1.21 the standard library has structured logging in log/slog:

go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))slog.SetDefault(logger)slog.Info("listening", "addr", cfg.Addr)

JSON lines are easy to search and awkward to read live, so switch to slog.NewTextHandler when APP_ENV is not production.

Now shutdown. Stopping or restarting a server sends SIGTERM. If your program does not handle it, the process is killed after the grace period and whatever was in flight is dropped - half-written responses, buffered writes, jobs mid-run. Go makes handling it short:

main.go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)defer stop()go func() {    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {        log.Fatal(err)    }}()<-ctx.Done()slog.Info("shutting down")shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)defer cancel()if err := srv.Shutdown(shutdownCtx); err != nil {    slog.Error("shutdown", "err", err)}db.Close()

Shutdown stops accepting new connections, waits for active requests to finish, and returns when they have or when the context expires. Two traps: ListenAndServe returns http.ErrServerClosed immediately when you call Shutdown, which is not an error and must not be logged as one; and Shutdown does not wait for hijacked connections such as websockets, so close those yourself. Keep the shutdown timeout shorter than the time the platform gives you, ten seconds being a reasonable ceiling. Graceful shutdown and health checks covers the health endpoint side, which for Go is a handler returning a constant.

Deploying from GitHub#

On the app lines, deploys come from GitHub only, through a GitHub App that uses short-lived tokens, so private repositories work without you pasting a personal access token anywhere. There are two switches, and they are independent:

  • Pull on every start. Each time the server starts, it fetches the latest commit of the branch it follows, then builds it.
  • Deploy on push. A push to the branch restarts a running server and starts a stopped one. Every deploy is recorded as one entry.

Combine that with the build-on-start behaviour and the deploy pipeline is short:

pushHTTPSHTTPGitHub repogo.mod, go.sum, sourceClientsPull on startlatest commitProxy slotTLS, X-Forwarded-Forgo buildon every startGo processlistens on the port
From a push to a running Go service

Two habits keep this safe. Deploy from a branch that only receives code that already passes tests, because deploy on push will restart production on whatever lands there. And keep secrets out of the repository, since the build reads the same tree you pushed. If a push fails to build, the compiler output is in the console, and the fix is another push.

The first time you push to an empty server, remember that every runtime line except Node.js starts with a small working page on its address until you deploy code, and that page never overwrites your files. If you see it after a deploy, the deploy did not take effect: look at the deploy record and the console before blaming the proxy.

Reverse proxy, domain and X-Forwarded-For#

Terminating TLS inside the Go program is possible with ListenAndServeTLS or autocert, and on a hosting plan it is usually the wrong choice: you take on certificate renewal, the HTTP-to-HTTPS redirect and the reload when a certificate changes. Serve plain HTTP on your allocated port and let the proxy do the rest.

App plans include a reverse-proxy slot. Point an A record at the address shown for the slot and the certificate is issued and renewed automatically, inside a 21-day window. Allow time for the record to propagate before you test; a certificate cannot be issued for a name that does not yet resolve to the address. Connecting a domain to a game server walks through the record itself, and the same steps apply to an app. What a reverse proxy does explains the header chain in more depth.

What this means for your handlers:

  • `r.RemoteAddr` is the proxy, not the visitor. The real client address arrives in the X-Forwarded-For header. If you log r.RemoteAddr or rate-limit on it, you are logging and limiting the proxy, and every visitor shares one bucket.
  • Take the right element. X-Forwarded-For is a comma-separated list. Anything a client sent themselves appears at the left; the address your proxy added is at the right. Trusting the leftmost value lets any client claim to be anyone. Because the proxy here is the only way in, trusting the header at all is reasonable, but take the last entry the proxy appended rather than the first.
  • Use middleware once, early. chi ships middleware.RealIP, which rewrites r.RemoteAddr from the forwarding headers. Install it only when the proxy is guaranteed to be in front, since it will believe whatever the headers say.
  • Build absolute URLs from `X-Forwarded-Proto`. Redirects, email links and OAuth callbacks need the public scheme. Reading r.TLS == nil always says plain HTTP behind a proxy, and the result is an http:// redirect loop.
go
func clientIP(r *http.Request) string {    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {        parts := strings.Split(xff, ",")        return strings.TrimSpace(parts[len(parts)-1])    }    host, _, _ := net.SplitHostPort(r.RemoteAddr)    return host}

Websockets work through the proxy but need heartbeats shorter than any idle timeout; websockets behind a reverse proxy covers the details, and it applies unchanged to gorilla/websocket and nhooyr.io/websocket.

Databases: Postgres, MySQL and Valkey#

A Go service on its own is stateless, which is what you want. State goes into a database, and there are two ways to get one.

Every app plan comes with database slots, created in the panel with a generated host, user and password. For something larger or separate, there are database hosting plans for PostgreSQL, MySQL 8.4 LTS, MongoDB, SQL Server Express and Valkey. Those are reached on the plan's own host and port with credentials generated per server. Put the resulting connection string in an environment variable on the Startup tab, never in the code.

For PostgreSQL, pgx is the driver to use directly or through database/sql. Its pool is pgxpool:

go
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))if err != nil {    log.Fatal(err)}defer pool.Close()

For MySQL, the standard choice is go-sql-driver/mysql under database/sql. Put parseTime=true in the DSN or you will get byte slices instead of time.Time values, and set the charset explicitly:

code
user:pass@tcp(host:3306)/appdb?parseTime=true&charset=utf8mb4&loc=UTC

database/sql pools by default and with no limits, which is the part to fix. Left alone it opens as many connections as concurrent queries demand, and a traffic spike turns into a connection limit error on the database side:

go
db.SetMaxOpenConns(10)db.SetMaxIdleConns(5)db.SetConnMaxLifetime(30 * time.Minute)db.SetConnMaxIdleTime(5 * time.Minute)

A Go process has one pool, not one per worker, so ten is generous for a service on one or two cores. Connection pools and limits has the arithmetic for the database side.

For caching, sessions and queues, use Valkey. It is the open-source fork of Redis and speaks the Redis protocol, so go-redis or rueidis connect to it unchanged. The instance is password-protected and saves to disk, using both AOF and snapshots. Valkey getting started covers connecting, and job queues with Valkey is the natural next step.

Run migrations deliberately. Applying them on every start from main is convenient and dangerous, because a restart loop will retry a half-applied migration. A separate command, or a tool such as golang-migrate or goose run by hand, is safer for anything beyond a toy.

Memory: GOMEMLIMIT, GOGC and build memory#

At the memory limit the kernel stops the container and it restarts clean, rather than swapping. For a Go service this is the main operational risk, because Go's garbage collector by default knows nothing about your limit. With GOGC=100 it lets the heap grow to roughly twice the live data before collecting, so a service with 300 MB of live data can reach 600 MB without anything being wrong.

Two settings fix that, and they are environment variables on the Startup tab:

VariableDefaultWhat it does
GOGC100Heap growth before a collection, as a percentage
GOMEMLIMITnoneSoft ceiling for the Go runtime's total memory
GOMAXPROCSlogical CPUsThreads that run Go code at once

GOMEMLIMIT takes a size with a unit suffix, for example GOMEMLIMIT=800MiB. It is a soft limit: as the runtime approaches it, the collector works harder to stay under, trading CPU for memory. Set it below the container limit, not at it, because the Go heap is not the only thing using memory - stacks, the runtime itself and cgo allocations count too. Roughly 10 to 20 percent below the plan's memory is a sensible start. On a 1 GB plan that is GOMEMLIMIT=800MiB.

A soft limit is not a cure for a leak. If live data really exceeds the limit, the collector runs almost continuously, CPU climbs, latency rises and you get a death spiral rather than a clean failure. If the graph on the console shows memory rising steadily after traffic has levelled off, find the leak (an unbounded map, a goroutine per request that never exits, a response body never closed) rather than raising the limit.

GOGC pairs with it. Lowering it, to 50, collects more often and uses less memory at a cost in CPU. Raising it, to 200, does the opposite. With GOMEMLIMIT set you can even set GOGC=off and let the limit alone drive collection, but that is for services whose live heap is small and well understood. Leave GOGC at its default until a graph gives you a reason.

CPU is a hard throttle to the share you bought: a plan with 0.5 vCPU runs your threads at that rate whatever the runtime thinks. Before Go 1.25, GOMAXPROCS defaulted to the number of logical CPUs of the machine rather than the quota, which can mean more scheduler threads than the container can use and stalls when it is throttled; later versions take the container quota into account. If you build with an older Go, set GOMAXPROCS to the whole number of cores your plan gives you, or import go.uber.org/automaxprocs.

Build memory is the separate question. The Go compiler is far lighter than a Rust release build, but it is not free, and the build happens inside the same container on every start. Figures from experience, not promises:

ProjectCold buildPeak build memory
Small API, a dozen dependencies20-60 seconds300-500 MB
Typical service, a few dozen modules1-3 minutes500-900 MB
Large tree, generated code, big protobufs3-8 minutes1 GB or more

On the smallest tier a typical service builds, but a large dependency tree can reach the limit and be stopped mid-compile, which shows up as a restart rather than a slow build. If that happens, go build -p 1 (or GOFLAGS=-p=1) compiles one package at a time and cuts peak memory at the cost of time. Beyond that, trim what you import: a dependency that pulls in a cloud SDK for one function is the usual culprit. For release builds, -ldflags="-s -w" strips symbol and debug information, shrinking the binary by a quarter or more without changing run-time memory.

What goes wrong#

The page loads on your laptop and times out on the server. Bound to localhost. Bind :PORT or 0.0.0.0:PORT.

`listen tcp :8080: bind: address already in use`. Either a second copy of your program is running, or you hard-coded a port that is not the one allocated. Read the environment variable.

The server never gets past the first start and the console shows a compiler error. The build failed. Reproduce locally with go build ./... on a clean checkout; a missing go.sum entry, an unused import or an uncommitted file are the usual causes.

`go: updates to go.mod needed; to update it: go mod tidy`. Your imports and go.mod disagree. Run go mod tidy locally and commit both files.

`go.mod requires go >= 1.24 (running go 1.23)`. The go line is newer than the toolchain. Lower it to the version you really need, or switch the line to a Go version that satisfies it.

The build is killed with no Go error. Out of memory in the compiler. Try go build -p 1, remove heavy dependencies, or move up a tier for the plan you build on.

Restarts every few minutes, memory graph climbing. Hitting the limit. Set GOMEMLIMIT, then look for the leak. Remember that three restarts in an hour raise a warning on the server page, and six lead to suspension.

`dial tcp: lookup db: no such host`. The database host is wrong for this environment. Use the host and port the panel or database plan gave you, not a name from your local compose file.

Every visitor has the same IP in the logs. You are reading r.RemoteAddr. Read X-Forwarded-For as above.

`x509: certificate signed by unknown authority` on outbound calls. The environment has no usable CA bundle, or the server you call uses a private CA. Test an outbound request in the first deploy so you meet this early.

Requests hang, then fail after a long wait. Handlers that call out without a deadline. Use http.Client{Timeout: ...} and pass r.Context() into database and HTTP calls so a cancelled request stops its work.

FAQ#

Which Go version should I target?

The lowest your code genuinely needs, written in the go line of go.mod. Anything you write with 1.22 or newer gets the method-aware ServeMux, and 1.21 added log/slog. Check the version the Go line provides and keep your go directive at or below it.

Do I need chi or gin?

No. The standard library is enough for most APIs, and handlers written for it move to chi without changes. Add a router when you want route groups and middleware chaining, and gin when you want its request binding and validation.

How do I get the real client IP?

Read X-Forwarded-For, which the proxy sets, and use the last entry it appended rather than the first. r.RemoteAddr is the proxy's address and will be the same for every visitor.

Why does a restart take longer than for a Node or Python app?

The server runs go build on every start, so a restart includes a compile. It is quick when little has changed and slower when it is cold. Keeping dependencies lean keeps it short.

Can I run migrations on start?

You can, and for a toy it is fine. For anything real run migrations as a separate step, because a service stuck in a restart loop will reattempt the same half-applied change.

Should I set GOMEMLIMIT?

Yes, if your service holds a meaningful amount of heap. Set it 10 to 20 percent below the plan's memory so the collector tightens up before the container is stopped. It stops the garbage collector from treating the whole machine as its own.