Back to Blog
Tutorial6 min read2026-07-11

Deploying a Go Fiber App: Static Builds, Migrations, and Prefork

Take a Fiber app to production: a 15 MB distroless image, pgx against managed Postgres, golang-migrate as a release step, and the prefork trap.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Go Fiber gives you Express-style ergonomics on top of fasthttp, and Go gives you the easiest deployment story in the industry: one static binary, no runtime, no node_modules, no virtualenv. The whole production setup fits in a two-stage Dockerfile and a migrations directory. There are exactly two places people trip — the prefork setting and connection pool sizing against a managed database — and we'll hit both.

The app

A Fiber app that's actually deployable, meaning it reads its port from the environment, exposes a health check, and shuts down cleanly:

// main.go
package main

import (
	"context"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/gofiber/fiber/v2"
	"github.com/jackc/pgx/v5/pgxpool"
)

func main() {
	pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatalf("db pool: %v", err)
	}
	defer pool.Close()

	app := fiber.New(fiber.Config{
		Prefork: false, // see below — leave this off in containers
	})

	app.Get("/healthz", func(c *fiber.Ctx) error {
		if err := pool.Ping(c.Context()); err != nil {
			return c.SendStatus(fiber.StatusServiceUnavailable)
		}
		return c.SendString("ok")
	})

	app.Get("/users/:id", func(c *fiber.Ctx) error {
		var name string
		err := pool.QueryRow(c.Context(),
			"SELECT name FROM users WHERE id = $1", c.Params("id")).Scan(&name)
		if err != nil {
			return c.SendStatus(fiber.StatusNotFound)
		}
		return c.JSON(fiber.Map{"name": name})
	})

	// Graceful shutdown: finish in-flight requests when the platform
	// sends SIGTERM during a deploy.
	go func() {
		sig := make(chan os.Signal, 1)
		signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
		<-sig
		_ = app.Shutdown()
	}()

	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}
	log.Fatal(app.Listen(":" + port))
}

The PORT fallback pattern matters: Fiber examples hardcode :3000, which works until a platform wants to tell your app where to listen. Reading the env var costs three lines and removes a whole category of config drift.

The prefork trap

Fiber has a Prefork option that spawns one child process per CPU core, each binding the port with SO_REUSEPORT. On a bare-metal box serving huge request volumes, it's a legitimate throughput win. In a container, turn it off.

Two reasons. First, containers get fractional CPU — a 0.25-CPU tier doesn't have "cores" to fork across, and the child processes just multiply memory usage for nothing. Second, your orchestrator already does the scaling: replicas are the unit of parallelism, and a process manager forking inside a pod fights the platform's process supervision and signal handling. Every Fiber-in-Kubernetes bug thread I've read that involves weird restarts or zombie processes ends with "we turned off prefork."

Migrations with golang-migrate

Go doesn't have a blessed migrations story, so pick one and be boring about it. golang-migrate is the least surprising:

migrate create -ext sql -dir migrations -seq create_users

That creates a numbered up/down pair:

-- migrations/000001_create_users.up.sql
CREATE TABLE users (
    id   BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL
);
-- migrations/000001_create_users.down.sql
DROP TABLE users;

Apply them with the connection string your platform gives you:

migrate -path ./migrations -database "$DATABASE_URL" up

Run this as a release step before the new version takes traffic — not in main(). Migrations at boot mean every replica races to migrate on every restart, and a bad migration turns into a crash loop instead of a failed deploy. golang-migrate takes a Postgres advisory lock so concurrent runs won't corrupt anything, but "won't corrupt" is a lower bar than "designed correctly."

Pool sizing against a managed database

This is the one that bites in week two, not day one. pgx's pool defaults to max(4, number of CPUs) connections per process. Managed databases have hard connection limits — on PandaStack's free tier that limit is 50, on Pro it's 300. Now do the math for your setup: 3 replicas × default pool on a multi-core node, plus a migration job, plus your laptop's psql session during an incident. On a small limit you can hit the ceiling with an ordinary setup, and the failure mode is new connections erroring while existing ones look fine.

Set the pool size explicitly in the DSN so it's visible in config rather than buried in library defaults:

pool, err := pgxpool.New(context.Background(),
	os.Getenv("DATABASE_URL")+"?pool_max_conns=10")

Budget it: connection limit ÷ (replicas + headroom for migrations and humans). Ten per replica is plenty for most APIs — Postgres connections are not where your throughput bottleneck is.

The Dockerfile: 15 MB, no shell

Go's static binaries mean the final image needs almost nothing in it:

FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/server .

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /bin/server /server
EXPOSE 3000
USER nonroot
ENTRYPOINT ["/server"]

Notes on the choices:

  • CGO_ENABLED=0 forces a fully static binary — pgx is pure Go, so nothing breaks. If you use a CGO-dependent SQLite driver, this is where you'd find out.
  • -ldflags="-s -w" strips debug info; typical savings are a few MB.
  • Copying go.mod/go.sum before the source means dependency downloads cache across builds. On a platform with metered build minutes, this is the difference between a 20-second rebuild and re-downloading your module graph every push.
  • distroless/static has no shell and no package manager — a smaller attack surface and a ~15 MB image. The trade-off: you can't exec into the container to poke around. Log properly and you won't miss it.

Deploying on PandaStack

The Fiber-shaped parts are done; the rest is wiring:

  1. 1Provision a managed PostgreSQL instance (14.x or 16.x — or MySQL, if that's your thing) from the dashboard.
  2. 2Connect the repo as a container app. With the Dockerfile above, PandaStack builds it as-is — rootless BuildKit in an ephemeral Kubernetes Job pod, deployed via Helm. No Dockerfile? Go is one of the auto-detected buildpack runtimes, and it'll produce a build without one. I'd still keep the Dockerfile — explicit beats detected once you care about image size.
  3. 3Attach the database. DATABASE_URL is injected into your container automatically, which is exactly what both the pgx pool and golang-migrate expect. No credentials to copy, nothing DB-related committed to the repo.
  4. 4Push. The build logs stream live, so when a module download stalls or a test fails you see it in real time instead of waiting for a generic "build failed."

Because a Fiber binary starts in milliseconds, it's a particularly good fit for scale-to-zero on the free tier — the cold start is dominated by the container coming up, not your app booting. Free-tier apps run on preemptible nodes and scale to zero when idle; for a production API you'd move to a paid compute tier and stay warm, but for a side project the $0 idle cost is the whole point.

The short version

Static binary, distroless image, prefork off, pool size explicit, migrations as a release step, PORT from the environment. That's the entire production story — Go really is that compact to operate. If you want to see the database wiring and git-push side of it without building the pipeline yourself, spin it up on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also