Back to Blog
Tutorial7 min read2026-07-11

How to Deploy a Gin App to Production (with PostgreSQL)

Take a Gin app from dev defaults to production: release mode, trusted proxies, graceful shutdown, goose migrations, and Postgres via DATABASE_URL.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Gin is probably the most widely used Go web framework, and it's easy to see why: fast router, sane middleware, and you can have an API responding in ten lines. But those ten lines run in debug mode, trust every proxy on the internet, and die mid-request on redeploy. Gin's defaults are development defaults. Here's what actually changes when you take it to production, plus the database and migration setup that goes with it.

Release mode, PORT, and trusted proxies

Three things Gin prints warnings about, and what to do with each.

Debug mode. Gin runs in debug mode unless told otherwise, logging every registered route and warning you on boot. Set it via environment variable rather than code, so local dev stays chatty:

GIN_MODE=release

The port. Unlike most Go frameworks, r.Run() with no arguments actually does read the PORT environment variable, falling back to :8080 if it's unset. So this is enough:

r := gin.Default()
r.Run() // uses $PORT if set, otherwise :8080

That's convenient, but I'd still avoid bare r.Run() in production — because of the next section.

Trusted proxies. By default Gin trusts all proxies, which means c.ClientIP() believes whatever X-Forwarded-For header a client sends. Behind a platform's ingress that's usually fine for the immediate hop, but if you use client IPs for anything that matters — rate limiting, audit logs — be explicit:

r.SetTrustedProxies(nil) // trust no proxy headers; ClientIP() = the TCP peer

or set it to your ingress's CIDR range if you need real client IPs forwarded through.

Graceful shutdown

r.Run() gives you no shutdown hook. Wrap the engine in an http.Server so SIGTERM — which every container platform sends before killing your pod — drains in-flight requests instead of dropping them:

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	r.SetTrustedProxies(nil)

	r.GET("/healthz", func(c *gin.Context) {
		c.Status(http.StatusOK)
	})

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	srv := &http.Server{
		Addr:              ":" + port,
		Handler:           r,
		ReadHeaderTimeout: 5 * time.Second,
	}

	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		log.Fatal(err)
	}
}

The ReadHeaderTimeout is there because a bare http.Server has no timeouts at all, and slow-header clients can pin connections open indefinitely.

Build and Dockerfile

The build is standard Go: one static binary.

CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o server .

For the image, here's an Alpine-based variant — slightly larger than distroless, but you get a shell for debugging and apk if you ever need a tool in the container:

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

FROM alpine:3.20
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 app
USER app
COPY --from=build /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Don't skip ca-certificates — a static Go binary in a bare image will fail every outbound HTTPS call with x509: certificate signed by unknown authority, and you'll only find out when your first webhook fires.

PostgreSQL: pool sizing is the real config

Connect with pgxpool using the single DATABASE_URL your platform provides:

import "github.com/jackc/pgx/v5/pgxpool"

cfg, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
	log.Fatal(err)
}
cfg.MaxConns = 10
cfg.MaxConnLifetime = 30 * time.Minute

pool, err := pgxpool.NewWithConfig(context.Background(), cfg)

The number that bites people is MaxConns. Managed Postgres instances cap total connections — on PandaStack it's 50 on the free tier, 300 on Pro — and that budget is shared across every app replica, your migration runner, and anyone connected via psql. Two replicas at the default pool size can eat a surprising share of a 50-connection cap. Ten per instance is a sensible starting point; raise it when you have evidence, not before.

Migrations with goose

[goose](https://github.com/pressly/goose) keeps migrations as plain SQL files with up/down sections:

-- migrations/00001_create_users.sql
-- +goose Up
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- +goose Down
DROP TABLE users;

Run them from your machine or CI against the managed database:

go install github.com/pressly/goose/v3/cmd/goose@latest
goose -dir ./migrations postgres "$DATABASE_URL" up

Or embed them so the binary can migrate itself:

import (
	"embed"

	"github.com/pressly/goose/v3"
)

//go:embed migrations/*.sql
var embedMigrations embed.FS

func migrateUp(db *sql.DB) error {
	goose.SetBaseFS(embedMigrations)
	if err := goose.SetDialect("postgres"); err != nil {
		return err
	}
	return goose.Up(db, "migrations")
}

Either way: forward-only in production. The Down sections are for development. If a migration is wrong in prod, write a new migration that fixes it.

Deploying on PandaStack

The pieces above are everything the platform needs:

  1. 1Provision a managed PostgreSQL (14.x or 16.x) from the dashboard.
  2. 2Connect the Git repo as a container app. The Dockerfile is detected and built with rootless BuildKit in an ephemeral Kubernetes job — no Docker daemon involved. No Dockerfile? The Go buildpack is auto-detected instead.
  3. 3Attach the database. DATABASE_URL is injected into the app automatically, so the pgxpool.ParseConfig(os.Getenv("DATABASE_URL")) line just works.
  4. 4Set GIN_MODE=release in the app's environment variables.
  5. 5Push. Build logs stream live, and every subsequent git push rebuilds and redeploys. Rollbacks and deployment history are there when a push goes sideways.

On the free tier, idle apps scale to zero and cold-start on the next request. Gin apps handle this about as well as anything can — a compiled binary boots in milliseconds, so the pause is mostly pod scheduling. Paid tiers skip scale-to-zero and run on stable nodes.

The five-minute review before you push

  • GIN_MODE=release set in the environment, not hardcoded
  • SetTrustedProxies configured deliberately
  • http.Server wrapper with Shutdown() on SIGTERM
  • Pool MaxConns sized against your database's connection cap
  • Migrations run as a deliberate step, forward-only
  • /healthz returns 200 without a database round-trip

None of this is exotic — it's the difference between a demo and a service. When you want to close the loop from git push to a live URL with Postgres already attached, try it on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also