Go Preview Environment in 5 Minutes — Live URLs for Every Branch, No Kubernetes
Your Go service compiles in 3 seconds. Your CI pipeline runs tests in 12. And then your designer asks for a link to the branch you pushed 40 minutes ago, and you're still writing a Docker Compose override so two PRs don't fight over port 8080.
Go's build speed is a superpower. The preview workflow around it usually isn't.
Most Go teams handle branch previews the same way: a shared staging server that one person deploys to at a time, or a hand-rolled CI script that provisions a cloud VM, wires up DNS, and hopes the cleanup cron didn't silently fail three weeks ago. Neither approach keeps up with how fast Go teams actually ship.
PreviewDrop gives every Go branch its own live URL — automatic deploys, HTTPS by default, database isolation, and zero CI configuration. Here's how to set it up in five minutes.
Why Go previews are different from frontend previews
Vercel and Netlify built the "push a branch, get a URL" workflow for JavaScript frontends. That workflow assumes your app compiles to static files or serverless functions. Go compiles to a single binary that listens on a port — a long-lived process that needs a container, not a static host.
The tools that do handle container-based previews — Railway, Render, Northflank — charge per resource or per second. A Go service with a Postgres dependency can cost $40–80/month in preview infrastructure alone for a team of three engineers. The meter runs whether anyone is reviewing the branch or not.
PreviewDrop takes a different approach: if your app has a Dockerfile (or if PreviewDrop can generate one from your go.mod), you get a live URL on every branch. Flat pricing, no per-resource metering, automatic teardown when the PR merges.
Step-by-step: your first Go preview
Step 1 — Make sure your Go service has a Dockerfile. If you already have one, skip to step 2. If not, here's a minimal multi-stage Dockerfile for a Go API server:
# Dockerfile
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/api
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]
This produces a ~12 MB image. The build stage compiles the binary; the runtime stage copies only the binary. No Go toolchain in the final image, no source code, no build artifacts.
Step 2 — Connect your repo. Install the PreviewDrop GitHub App on your Go repo. It takes about 60 seconds. The app receives push and PR webhooks automatically — no workflow files to write.
Step 3 — Set environment variables. In the PreviewDrop dashboard, add the variables your Go service needs:
PORT=8080
DATABASE_URL=postgres://user:pass@host:5432/preview_db
API_SECRET=your-preview-secret
Use a dedicated development database. PreviewDrop previews are ephemeral — point DATABASE_URL at a shared dev Postgres instance, or use a database-branching service for per-branch isolation.
Step 4 — Push a branch. That's it. PreviewDrop detects Go from your go.mod, builds the Docker image, starts the container, and posts a live HTTPS URL to your PR within ~60 seconds on warm redeploys.
git checkout -b feature/new-api-endpoint
git push origin feature/new-api-endpoint
# URL posted to your PR automatically:
# https://pr-127.previewdrop.dev
What PreviewDrop detects in your Go project
When PreviewDrop scans your repo, it looks for:
go.mod— confirms it's a Go project, reads the module path and Go versionmain.goorcmd/*/main.go— identifies the entry pointDockerfile— if present, uses it directly; if not, generates a multi-stage buildMakefileor build scripts — detects custom build commands if you've configured them
The build process compiles the binary with CGO_ENABLED=0 for a static binary, copies it into a minimal Alpine image, and starts the container. If your project uses go generate or code generation, you can configure a pre-build command in the project settings.
Database strategy for Go previews
Go services typically connect to Postgres via database/sql with pgx or lib/pq, or use an ORM like GORM or Bun. The database question is the same as for any backend stack: do you share a dev database or isolate per branch?
Option 1 — Shared dev database. Point all previews at one Postgres instance. Simplest setup, works well for read-heavy testing. The trade-off: two branches running conflicting migrations will step on each other.
Option 2 — Per-branch isolation. Use a database-branching service that creates lightweight Postgres clones in under a second. Set the branch name as an environment variable, and each preview gets its own isolated database. Here's how your Go service reads it:
// config.go
package config
import "os"
func DatabaseURL() string {
// PreviewDrop injects PREVIEW_URL at deploy time
// Use DATABASE_URL for the database connection
return os.Getenv("DATABASE_URL")
}
func Port() string {
if p := os.Getenv("PORT"); p != "" {
return p
}
return "8080"
}
Option 3 — SQLite for previews. For simpler Go services, use SQLite with modernc.org/sqlite (a pure-Go SQLite driver, no CGO required). This avoids external database dependencies entirely and works well for services that don't need Postgres-specific features.
Add a health check endpoint
Preview platforms need a health check to verify the container is ready. Add one to your Go HTTP server:
// health.go
package api
import (
"database/sql"
"encoding/json"
"net/http"
)
func HealthHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := db.Ping()
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{
"status": "unhealthy",
"error": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
}
Wire it into your router:
// main.go
mux.HandleFunc("/health", api.HealthHandler(db))
PreviewDrop polls /health after container start. When it returns 200, the preview is marked ready and the URL is posted to the PR.
Performance on Go
Go deploys benefit from Docker layer caching and Go's fast compile times. Here's what a warm redeploy looks like:
go mod download (cached) .... 6s
go build .................. 14s
container start ........... 3s
stability poll ............ 12s
─────────────────────────────────
Total: ~35 seconds
The first deploy on a branch (cold) takes 2–3 minutes — most of that is the initial go mod download and Docker layer pull. After that, every push rebuilds only the application code. The Go compiler's speed means the build stage is rarely the bottleneck.
Go-specific gotchas
CGO and static binaries. If your Go service uses CGO (for SQLite with mattn/go-sqlite3, for example), you need CGO_ENABLED=1 and a build image with gcc. The generated Dockerfile handles this automatically if PreviewDrop detects CGO dependencies in your go.mod.
go.sum must be committed. PreviewDrop runs go mod download during the build. If go.sum is in .gitignore, the build fails with a checksum mismatch. Run go mod tidy and commit go.sum before connecting your repo.
Port binding. Go's net/http defaults to localhost if you don't specify a host. Always bind to 0.0.0.0 in containerized environments:
// Correct — binds to all interfaces
log.Fatal(http.ListenAndServe("0.0.0.0:"+port, handler))
// Wrong — only accessible inside the container
log.Fatal(http.ListenAndServe("localhost:"+port, handler))
Graceful shutdown. Preview containers are stopped when the PR merges or the TTL expires. Handle SIGTERM so in-flight requests complete:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := &http.Server{Addr: "0.0.0.0:" + port, Handler: handler}
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(shutdownCtx)
When Go previews replace the staging server
A 4-person Go team at an infrastructure startup replaced their shared staging server with per-branch previews. Before: one staging instance, a Slack channel named #staging-lock, and an average 45-minute wait to test a branch. After: every PR gets its own URL, zero coordination, and the #staging-lock channel was archived.
The setup didn't add a single line of CI configuration. It didn't require Kubernetes. It didn't change the team's development workflow. It just gave every branch a URL.
For a deeper comparison of DIY vs managed approaches, read our GitHub Actions preview environment tutorial — it walks through building previews from scratch and the tradeoffs involved.
Start your first Go preview
PreviewDrop works with any Go service — from a single main.go API to a multi-service monorepo with shared packages. The free tier gives you 2 concurrent previews with no credit card required.
Ready to give every branch a live URL?
Free tier — 2 concurrent previews, no credit card required.
Start free