Platform Guides: Render & Leapcell
Deploying Go: Render, Leapcell, and VPS
Overview
Go’s static binary makes PaaS and VPS deployment straightforward: build once, run the file. In 2026 the common paths are managed platforms (Render, Leapcell, Fly, Railway) for speed, and a small VPS with systemd when you want full control and predictable cost.
This chapter walks through Render, Leapcell-style serverless Go, a production systemd unit, environment and secrets, health checks, and when to choose each model.
Deployment Models Compared
| Model | Cold start | Cost shape | Ops burden | Good for |
|---|---|---|---|---|
| PaaS (Render web service) | Warm (always-on or min instances) | Monthly / usage | Low | APIs, webhooks, workers |
| Serverless binary (Leapcell, similar) | Milliseconds–low seconds | Scale-to-zero | Very low | Spiky or low-traffic APIs |
| VPS + systemd | None (always running) | Flat $ | Medium | Homelab, steady load, custom networking |
| Containers on K8s | Depends | Cluster fixed + usage | High | Multi-service platforms |
Go fits all four: same binary shape, different process supervisors.
Render (render.com)
Render detects Go repositories and builds with the Go toolchain on their builders.
Typical setup
- Push the repo to GitHub/GitLab.
- New Web Service → connect the repo.
- Configure:
| Field | Example |
|---|---|
| Runtime | Go |
| Build Command | go build -o bin/server ./cmd/server |
| Start Command | ./bin/server |
| Health Check Path | /healthz |
Production build command
Prefer flags you would use locally for release:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/server ./cmd/serverIf you need a specific module path:
go build -o bin/server ./cmd/apiEnvironment and ports
Render injects PORT. Always bind to PORT, not a hard-coded 8080:
package main
import (
"log"
"net/http"
"os"
"time"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("hello from render"))
})
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("listening on %s", srv.Addr)
log.Fatal(srv.ListenAndServe())
}Databases and private networking
- Managed Postgres/Redis: set
DATABASE_URL/REDIS_URLas env vars (never commit them). - Private services: talk over the internal hostname Render provides; do not expose internal ports publicly.
- Migrations: run as a one-off job or release command, not on every web dyno boot if multiple instances race.
# Example release / pre-deploy idea (platform-specific)
./bin/migrate up
./bin/serverPros and cons
Pros: Free/hobby tiers, managed TLS, Git-driven deploys, managed data stores, low ops.
Cons: Less control than a VPS; cold starts on free tiers; build minutes and instance sizes are the cost levers.
Leapcell (and Serverless Go)
Leapcell targets serverless Go: scale to zero when idle, spin up quickly (often Firecracker-style microVMs). Unlike AWS Lambda’s custom handler contract, many of these platforms run a normal net/http server binary.
Conceptual difference from Lambda
AWS Lambda: event JSON --> handler(ctx, event) --> response JSON
Leapcell-like: cold start binary --> listen :PORT --> ordinary HTTP
You keep idiomatic Go HTTP code, middleware, and routers.
Config sketch
# leapcell.yaml (illustrative)
runtime: go
entrypoint: ./server
# memory / region set in dashboard or CIBuild locally the same way:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o server ./cmd/serverDesign for cold starts
- Defer heavy work — open DB pools on first request or in a short
initwith timeouts; avoid multi-second global init. - Small binary — strip symbols; avoid huge embedded assets unless needed.
- Idempotent migrations — do not migrate schema on every cold start.
- Stateless handlers — session state in Redis/DB/cookies, not process memory alone.
var dbOnce sync.Once
var db *sql.DB
var dbErr error
func getDB(ctx context.Context) (*sql.DB, error) {
dbOnce.Do(func() {
dsn := os.Getenv("DATABASE_URL")
db, dbErr = sql.Open("pgx", dsn)
if dbErr != nil {
return
}
db.SetMaxOpenConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
dbErr = db.PingContext(pingCtx)
})
return db, dbErr
}VPS Deployment with systemd
For a $5–15/mo Linux VPS (Hetzner, DigitalOcean, Linode):
1. Provision a non-root user
sudo useradd --system --home /var/lib/myapp --shell /usr/sbin/nologin myapp
sudo mkdir -p /var/lib/myapp /etc/myapp
sudo chown myapp:myapp /var/lib/myapp2. Install the binary
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o myapp ./cmd/server
scp myapp deploy@host:/tmp/myapp
ssh deploy@host 'sudo install -o myapp -g myapp -m 0755 /tmp/myapp /usr/local/bin/myapp'3. Environment file
# /etc/myapp.env (mode 0640, root:myapp)
PORT=8080
DATABASE_URL=postgres://...
LOG_LEVEL=info4. Unit file
# /etc/systemd/system/myapp.service
[Unit]
Description=My Go App
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=myapp
Group=myapp
EnvironmentFile=/etc/myapp.env
WorkingDirectory=/var/lib/myapp
ExecStart=/usr/local/bin/myapp
Restart=on-failure
RestartSec=2
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/myapp
AmbientCapabilities=
CapabilityBoundingSet=
LimitNOFILE=65536
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo journalctl -u myapp -f5. Reverse proxy (Caddy or nginx)
Terminate TLS at the edge; proxy to 127.0.0.1:8080:
api.example.com {
reverse_proxy 127.0.0.1:8080
}
Health, Metrics, and Zero-Downtime
Regardless of platform:
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if err := pingDependencies(r.Context()); err != nil {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})- Liveness (
/healthz): process is up. - Readiness (
/readyz): safe to receive traffic (DB reachable, migrations done).
For rolling deploys on a VPS, run two units behind the proxy or use a brief drain period with systemctl reload patterns and connection draining.
Secrets Hygiene
| Do | Don’t |
|---|---|
Platform secret store / EnvironmentFile with tight permissions |
Commit .env with production credentials |
| Rotate DB passwords and API keys on a schedule | Share one root DB user across apps |
| Inject secrets at runtime | Bake secrets into the binary or image layers |
| Scope tokens to least privilege | Put admin tokens in frontend env |
Choosing a Path
Need scale-to-zero + almost no ops? --> Leapcell-style serverless
Need managed Postgres + Git deploys? --> Render / similar PaaS
Need full control / homelab / cost? --> VPS + systemd + Caddy
Multi-team multi-service mesh? --> Containers / K8s (separate chapter)
Production Checklist
- Bind to
PORT(or platform-provided address) /healthzand/readyzimplemented and wired in the platform- Release build flags (
CGO_ENABLED=0,-trimpath,-s -w) - Secrets only via platform env / secret store /
EnvironmentFile - TLS at the edge (platform or Caddy/nginx)
- Graceful shutdown on
SIGTERM(PaaS and systemd both send it) - Structured logs to stdout/stderr (platforms capture them)
- Database connection limits sized for instance count
- Document rollback: previous binary tag or image digest
Common Pitfalls
- Hard-coded port — works locally, fails on Render/Leapcell.
- Listening on 127.0.0.1 only — fine behind local Caddy; wrong if the platform expects
0.0.0.0. - Migrate on every boot with N replicas — racey schema changes; use one-shot jobs.
- Huge cold start init — loading models, syncing caches, or compiling templates at package
inittime. - Root systemd service — unnecessary privilege; use a dedicated user.
- No disk quota awareness — writing unbounded logs/uploads under
/var/libfills the VPS.
Exercises
- PORT wiring — Deploy a tiny handler that prints
PORTand the listen address; verify on a PaaS free tier or withPORT=9090 ./serverlocally. - systemd unit — Install a binary as a non-root service with
ProtectSystem=strictand confirmjournalctlshows clean restarts afterkill -TERM. - Readiness gate — Make
/readyzfail until a file/var/lib/myapp/readyexists; flip it and watch a reverse proxy start routing. - Cold-start budget — Measure time from process start to first successful
/healthzwith artificial 2s DB connect delay; refactor init to stay under 500ms when DB is up. - Secret file perms — Create
/etc/myapp.envas0640 root:myappand prove the service user can read it while an unprivileged login cannot.
More examples
PORT injection and dual health endpoints
PaaS injects PORT and probes liveness separately from readiness. This program prints the resolved listen port and exercises both endpoints via httptest.
mkdir -p /tmp/go-paas-port && cd /tmp/go-paas-port
go mod init example.com/paas-portSave as main.go:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
)
func port() string {
if p := os.Getenv("PORT"); p != "" {
return p
}
return "8080"
}
func main() {
os.Setenv("PORT", "10000")
fmt.Println("listen :"+port())
var dbOK atomic.Bool
dbOK.Store(false)
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "alive")
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
if !dbOK.Load() {
http.Error(w, "db warming", http.StatusServiceUnavailable)
return
}
fmt.Fprintln(w, "ready")
})
ts := httptest.NewServer(mux)
defer ts.Close()
r, _ := http.Get(ts.URL + "/healthz")
r.Body.Close()
fmt.Println("healthz:", r.StatusCode)
r, _ = http.Get(ts.URL + "/readyz")
r.Body.Close()
fmt.Println("readyz cold:", r.StatusCode)
dbOK.Store(true)
r, _ = http.Get(ts.URL + "/readyz")
r.Body.Close()
fmt.Println("readyz warm:", r.StatusCode)
}go run .Expected output:
listen :10000
healthz: 200
readyz cold: 503
readyz warm: 200
Config from env file shape (KEY=VALUE)
mkdir -p /tmp/go-paas-env && cd /tmp/go-paas-env
go mod init example.com/paas-envSave as main.go:
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func loadEnvFile(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
out := map[string]string{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
out[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
return out, sc.Err()
}
func main() {
path := "app.env"
_ = os.WriteFile(path, []byte("# demo\nPORT=7777\nAPP_ENV=staging\n"), 0o600)
defer os.Remove(path)
cfg, err := loadEnvFile(path)
if err != nil {
panic(err)
}
fmt.Println("PORT:", cfg["PORT"])
fmt.Println("APP_ENV:", cfg["APP_ENV"])
fmt.Println("keys:", len(cfg))
}go run .Expected output:
PORT: 7777
APP_ENV: staging
keys: 2
Runnable example
PaaS platforms inject PORT and probe health. This program binds to PORT, exposes /healthz and /readyz, and demonstrates readiness gating—the same contract Render, Leapcell-style hosts, and systemd expect.
mkdir -p /tmp/go-paas-app && cd /tmp/go-paas-app
go mod init example.com/paas-appSave as main.go:
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"sync/atomic"
"time"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "0" // ephemeral for local demo
}
var ready atomic.Bool
ready.Store(false)
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {
if !ready.Load() {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ready"))
})
mux.HandleFunc("GET /", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "hello from paas-shaped go")
})
ln, err := net.Listen("tcp", "127.0.0.1:"+port)
if err != nil {
log.Fatal(err)
}
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
base := "http://" + ln.Addr().String()
fmt.Println("listen_addr:", ln.Addr().String())
fmt.Println("PORT env:", os.Getenv("PORT"))
// Simulate warmup: live before ready.
check(base+"/healthz", "healthz-before-ready")
check(base+"/readyz", "readyz-before-ready")
ready.Store(true)
fmt.Println("warmup complete; marking ready")
check(base+"/readyz", "readyz-after-ready")
check(base+"/", "root")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
fmt.Println("shutdown complete")
}
func check(url, label string) {
resp, err := http.Get(url)
if err != nil {
fmt.Printf("%s: error %v\n", label, err)
return
}
defer resp.Body.Close()
fmt.Printf("%s: %d\n", label, resp.StatusCode)
}go run .
PORT=9090 go run .Expected output (illustrative):
listen_addr: 127.0.0.1:54321
PORT env:
healthz-before-ready: 200
readyz-before-ready: 503
warmup complete; marking ready
readyz-after-ready: 200
root: 200
shutdown complete
What to notice
- Always prefer
PORTfrom the environment; hard-coded8080fails on managed platforms. - Liveness (
/healthz) can succeed while readiness (/readyz) is 503 during migrations or dependency warmup. - Listen on all interfaces in real deploys (
:PORT); this demo uses127.0.0.1for a self-contained local run.
Try next
- Run with
PORT=9090and curl from another terminal. - On
SIGTERM, flipreadyto false, sleep briefly (connection drain), thenShutdown.