Back to Blog
Tutorial7 min read2026-07-11

How to Deploy a Go Echo App with PostgreSQL

Ship an Echo app as a static binary: PORT handling, a distroless Dockerfile, embedded migrations, and managed Postgres wired in via DATABASE_URL.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Echo is one of the frameworks Go developers actually ship with: a thin router, solid middleware, and almost nothing between your handler and net/http. The good news is that Go deployments are structurally simpler than most stacks — you compile one static binary and run it. The failures I see are almost never about the framework. They're about three things: port binding, CA certificates in minimal images, and migrations. Let's handle all three properly.

Getting the server production-ready

Echo does not read PORT for you. If you hardcode e.Start(":8080") and your platform routes traffic to a different port, your app will build fine and then fail its first health check. Read the environment, and add graceful shutdown while you're at it — every container platform sends SIGTERM before killing a pod, and a server that drops in-flight requests on redeploy looks like random 502s to your users.

package main

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

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()
	e.HideBanner = true
	e.Use(middleware.Logger(), middleware.Recover())

	e.GET("/healthz", func(c echo.Context) error {
		return c.NoContent(http.StatusOK)
	})

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

	go func() {
		if err := e.Start(":" + port); err != nil && err != http.ErrServerClosed {
			e.Logger.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 := e.Shutdown(ctx); err != nil {
		e.Logger.Fatal(err)
	}
}

Two details worth noting. ":" + port binds all interfaces — never bind 127.0.0.1 in a container, because the platform's ingress can't reach loopback. And the /healthz endpoint is not optional: it's what lets the platform know your new deployment is alive before it shifts traffic.

The production build

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

CGO_ENABLED=0 produces a fully static binary with no libc dependency, which is what lets you run it in a distroless or scratch image. -s -w strips debug symbols and typically cuts the binary size meaningfully. -trimpath removes your local filesystem paths from stack traces.

The classic trap with fully static binaries: scratch images contain no CA certificates. The first time your app makes an outbound HTTPS call — a webhook, an S3 upload, a payment API — you get x509: certificate signed by unknown authority. Use distroless/static instead of scratch; it ships CA certs and a nonroot user.

A Dockerfile for Echo

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 /server .

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

Copying go.mod and go.sum before the source means the dependency download layer is cached — rebuilds after a code-only change skip go mod download entirely. The final image is your binary plus CA certs, usually in the tens of megabytes.

Connecting PostgreSQL with pgx

Use pgxpool — it parses a DATABASE_URL connection string directly, no manual splitting:

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

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

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

Set MaxConns deliberately. Managed databases cap connections — on PandaStack the free tier allows 50, Pro 300 — and pgxpool's default scales with CPU count. Ten connections per app instance is plenty for most workloads, and it leaves headroom for a second replica plus your migration runner without hitting the cap.

Migrations with golang-migrate

Go has no Rails-style migration convention, so pick a tool and embed the migrations in the binary. With [golang-migrate](https://github.com/golang-migrate/migrate) and embed:

import (
	"embed"

	"github.com/golang-migrate/migrate/v4"
	_ "github.com/golang-migrate/migrate/v4/database/postgres"
	"github.com/golang-migrate/migrate/v4/source/iofs"
)

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

func runMigrations(databaseURL string) error {
	src, err := iofs.New(migrationsFS, "migrations")
	if err != nil {
		return err
	}
	m, err := migrate.NewWithSourceInstance("iofs", src, databaseURL)
	if err != nil {
		return err
	}
	if err := m.Up(); err != nil && err != migrate.ErrNoChange {
		return err
	}
	return nil
}

Migration files live in migrations/ as numbered pairs: 000001_create_users.up.sql and 000001_create_users.down.sql. golang-migrate takes a Postgres advisory lock while running, so two app instances starting at once won't both apply the same migration — but the cleaner pattern is still to run migrations as a separate step before the new version takes traffic, not buried in main() where a slow ALTER TABLE blocks your health check.

Treat migrations as forward-only in production. m.Down() exists; the discipline to never need it is worth more.

Deploying on PandaStack

With the Dockerfile above in your repo root, the deploy itself is short:

  1. 1Provision a managed PostgreSQL instance (14.x or 16.x are what run in production) from the dashboard.
  2. 2Connect your Git repository as a container app. PandaStack detects the Dockerfile and builds it — with rootless BuildKit in an ephemeral Kubernetes job, so there's no host Docker daemon in the path. If you skip the Dockerfile entirely, the Go buildpack is auto-detected and handles the build for you.
  3. 3Attach the database to the app. DATABASE_URL is injected automatically — you never copy a password between dashboards.
  4. 4Add any other environment variables (API keys, feature flags) in the app settings.
  5. 5Push to your branch. The build runs, logs stream live so you can watch go mod download and the compile in real time, and the app goes live behind automatic SSL.

One thing worth knowing about the free tier: idle apps scale to zero and cold-start on the next request. For most runtimes that's a noticeable pause. For Go it's close to the best case — a static binary boots in milliseconds, so nearly all of the cold-start time is scheduling, not your app. If you need always-on, paid tiers run on stable nodes without scale-to-zero.

Pre-flight checklist

  • Reads PORT from the environment, binds 0.0.0.0
  • Handles SIGTERM with e.Shutdown()
  • /healthz returns 200 without touching the database (a DB blip shouldn't restart your app)
  • Built with CGO_ENABLED=0, running on distroless/static
  • MaxConns set explicitly on the pool
  • Migrations embedded, forward-only, run before traffic shifts

That's the whole surface area. Go plus Echo is about as little deployment machinery as a real backend can have — if you want to see the git-push-to-live loop with the database already wired in, 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