All posts
gotutorialpreview-environments

Go Branch Previews: A Live URL for Every PR, No Dockerfile

PreviewDrop Team·November 2, 2026·7 min read

Your Go service compiles in four seconds and boots in under a hundred milliseconds. So why does the review for your latest pull request consist of a screenshot and a comment that says "looks right to me"?

Because the bottleneck was never the build. It's that nobody has a running copy of the branch to click. Standing up a second environment means a second host, a second set of secrets, a second deploy script — and nobody volunteers for that on a Tuesday. So the team shares one staging box, and the reviewer tests whatever happened to be deployed last.

Go is unusually well suited to fixing this. A Go binary has no runtime to install, no interpreter version to pin, and no dependency tree to resolve at boot. One static executable, one port, done. Here is how to get a live preview URL on every pull request without writing a Dockerfile or a CI pipeline.

Why Go previews are cheap

Most preview-environment pain comes from the build step. A Rails app needs the right Ruby version, native extensions compiled, and a gem cache warm before it can even start. A Node app needs a lockfile that matches the registry. Those are all solvable, but each one is a place where a preview breaks for a reason that has nothing to do with your code.

Go collapses that. go build produces a single binary that carries its own runtime. There's no Gemfile.lock drift, no node_modules mismatch, no "works on my machine" because the machine is the binary. When the build succeeds, the artifact is complete.

The second advantage is startup time. A Go HTTP server is listening before most frameworks have finished loading. That matters for previews specifically, because a preview that takes ninety seconds to become reachable is a preview people stop clicking. If your container boots in under a second, the reviewer's loop stays tight.

Set up: three things to get right

PreviewDrop detects Go from go.mod and builds the project for you. There's no Dockerfile to write and no YAML pipeline to maintain — the stack is auto-detected, and you can override the install, build, and start commands in Project Settings if your layout is unusual.

Three details decide whether the preview actually serves traffic.

1. Bind to the injected PORT, not a hard-coded 8080

This is the single most common reason a Go preview builds successfully and then shows a connection error. PreviewDrop injects a PORT environment variable and routes traffic to it. If your main.go hard-codes :8080, the router has nothing to talk to.

Read the variable, with a sane local fallback so go run . still works on your laptop:

package main

import (
	"log"
	"net/http"
	"os"
)

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

	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	})

	log.Printf("listening on :%s", port)
	if err := http.ListenAndServe(":"+port, mux); err != nil {
		log.Fatal(err)
	}
}

The same rule applies if you use SERVER_PORT or a config struct — map it from PORT at startup rather than committing a fixed value.

2. Listen on all interfaces

ListenAndServe(":"+port, ...) binds to every interface, which is what you want. The failure mode is subtler: some teams bind explicitly to 127.0.0.1 for local development and carry that into the deployed binary. Inside a container, localhost is the container itself, so external traffic never arrives. Bind to 0.0.0.0 or leave the host empty.

3. Build a static binary

If your project uses cgo — often pulled in by SQLite drivers or certain crypto packages — the resulting binary depends on shared libraries that may not exist in a slim runtime image. Build with cgo disabled unless you specifically need it:

CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o app .

-trimpath strips local filesystem paths from the binary, and -s -w drops the symbol table and debug info. Neither changes behaviour; both make the artifact smaller and the build reproducible. If your build command needs this, set it in Project Settings rather than relying on the default.

Environment variables and the preview hostname

PreviewDrop sets PORT, HOST, and HOSTNAME on every preview. That's the full list — there is no injected PREVIEW_URL, so don't write code that expects one.

That constraint matters for one specific Go pattern: CORS and redirect URLs. If your API validates an Origin header against an allowlist, or builds a callback URL from a configured base, a preview hostname won't match production. Preview hostnames follow prv-<id>.previews.previewdrop.dev, so either match that shape with a pattern:

var previewHost = regexp.MustCompile(`^https://prv-[a-z0-9-]+\.previews\.previewdrop\.dev$`)

func originAllowed(origin string) bool {
	return origin == "https://app.example.com" || previewHost.MatchString(origin)
}

Or derive the base URL from the incoming request instead of a setting. Both are one-line changes, and both prevent the "preview loads but every API call 403s" debugging session.

Your other secrets — database URLs, API keys — go in the project's environment variables and are applied to every preview. A preview runs your app's container; it does not come with a database attached, so point previews at a shared development database or a throwaway instance you control.

Add a health endpoint, then connect the repo

The /healthz handler in the snippet above is worth keeping. A preview that returns 200 on a known path is verifiable; a preview that returns 200 on / because your SPA catches every route is not. Give the platform — and your uptime monitor — something honest to check.

With that in place, the setup is short:

  1. Connect the repository to PreviewDrop. It reads go.mod and detects Go.
  2. Paste your environment variables into the project settings.
  3. If your entrypoint isn't the module root, set the build and start commands explicitly.
  4. Open a pull request.

The first build compiles your module and warms the dependency cache; later builds reuse it. When the build finishes, a comment lands on the pull request with the URL. Push again and the same URL updates in place. Close or merge the PR and the preview is torn down.

If a build can't start the container, the deployment is marked failed with the reason shown in the dashboard rather than sitting in queued and counting against your concurrent-preview limit.

What this changes about review

The practical difference is that a reviewer stops reading a diff in isolation. They click a link, hit the endpoint, and see the actual JSON. A frontend engineer points their local dev server at the preview's API instead of waiting for a backend deploy. A product manager verifies the behaviour without installing Go.

For a Go team specifically, this is nearly free. You already have a fast build and a fast boot; the missing piece was only ever the hosting and the routing. Once each branch has its own URL, the shared staging server stops being a coordination problem and goes back to being what it should have been — a rehearsal for production.

Start with one branch

You don't need to migrate your whole workflow. Pick the pull request that's been sitting in review the longest, connect the repo, and push a commit to that branch. If the preview URL loads and your health endpoint answers, you've proved the loop.

Read the Go framework guide for the full walkthrough, or sign up and put a live URL on your next pull request. The Free plan covers two concurrent previews across three projects with a four-hour lifetime — enough to try this on a real branch today. Paid plans start at $19/mo for the workspace, not per seat.

Want previews on your own pull requests? Start free — 2 concurrent previews, no credit card.