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:
- 1Provision a managed PostgreSQL instance (14.x or 16.x are what run in production) from the dashboard.
- 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.
- 3Attach the database to the app.
DATABASE_URLis injected automatically — you never copy a password between dashboards. - 4Add any other environment variables (API keys, feature flags) in the app settings.
- 5Push to your branch. The build runs, logs stream live so you can watch
go mod downloadand 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
PORTfrom the environment, binds0.0.0.0 - Handles
SIGTERMwithe.Shutdown() /healthzreturns 200 without touching the database (a DB blip shouldn't restart your app)- Built with
CGO_ENABLED=0, running ondistroless/static MaxConnsset 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.