Deploy a Go web app to Hetzner
Follow this on your own server. Start free, no card needed
A Go binary is unusually easy to put on a server: there’s no interpreter to install, no package manager to run, no language runtime version to pin on the host at all. Compile it once, copy it over, and it runs. That simplicity doesn’t remove the rest of the work though. You still need a server, a domain pointed at it, a database, HTTPS in front of everything, a backup that survives the server disappearing, and a short way to ship your next change. This guide builds all of that by hand, then shows the shortcut.
The app itself is deliberately small: a net/http service using only the standard library, with one route that reads and writes to Postgres through pgx/v5, so there’s a real reason to talk about a database connection and not just a static “hello world.”
What you’ll need
- A Hetzner Cloud account and a server. The cost-optimized CX23 (2 vCPU, 4 GB memory, 40 GB NVMe) lists at €5.99/month excluding VAT. Check current pricing and availability in the Hetzner Cloud Console before ordering, since cloud lineups and prices do shift.
- A domain name you can add a DNS record to.
- An SSH key pair on your machine (
ssh-keygen -t ed25519if you don’t already have one). - Go 1.27 or newer installed locally, to write and build the app before it ever touches the server.
- About fifteen minutes for the server and DNS to settle, mostly spent waiting rather than typing.
Step 1: Create the server and point a domain at it
In the Hetzner Cloud Console, open (or create) a project, then choose Add Server. Pick a location: Nuremberg, Falkenstein or Helsinki keep your data in the EU, while Ashburn, Hillsboro or Singapore are also on offer if your users are elsewhere. Choose Ubuntu 24.04 as the image, then under server type pick the Shared vCPU CX23 (2 vCPU, 4 GB memory, 40 GB NVMe). Under SSH keys, add the public key from your machine instead of relying on a generated password, then create the server.
Once it boots, the console shows its public IPv4 address. Connect to confirm access:
ssh root@YOUR_SERVER_IP
You should see the Ubuntu welcome banner and land at a root shell. If the connection is refused, double check the SSH key attached to the server matches the private key on your machine.
Add an A record at your DNS provider for the hostname you want to use, pointing at that IP:
Type: A
Name: myapp (or @ for the bare domain)
Value: YOUR_SERVER_IP
TTL: 300
Check it resolves before moving on, since Caddy needs this to already work in a later step:
dig +short myapp.example.com
You should see your server’s IP printed back. If you get nothing, wait a few minutes and try again.
Hetzner doesn’t put a firewall in front of a new server by default, so lock the host down to just what you need with ufw:
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable
Step 2: Install Docker on the server
Docker’s own install script handles both the engine and the Compose plugin on a fresh Ubuntu box:
apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl enable --now docker
Check the install:
docker --version
docker compose version
You should see a Docker version and a Compose version printed, not a “command not found” error.
Step 3: Write the Go app
On your own machine, set up the module:
mkdir myapp && cd myapp
go mod init myapp
go get github.com/jackc/pgx/v5
go mod init and go get write a go.mod like this (your exact dependency versions may differ slightly depending on when you run it, since go mod tidy resolves them at request time):
module myapp
go 1.27
require github.com/jackc/pgx/v5 v5.7.6
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
Now main.go, using only net/http from the standard library plus pgxpool for the database:
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
var pool *pgxpool.Pool
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL is not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var err error
pool, err = pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("unable to create connection pool: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
log.Fatalf("unable to reach postgres: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS visits (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`); err != nil {
log.Fatalf("unable to create visits table: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", handleHealthz)
mux.HandleFunc("/visits", handleVisits)
addr := "0.0.0.0:" + port
log.Printf("listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}
func handleHealthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
type visitsResponse struct {
Count int64 `json:"count"`
}
func handleVisits(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if _, err := pool.Exec(ctx, "INSERT INTO visits DEFAULT VALUES"); err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
var count int64
if err := pool.QueryRow(ctx, "SELECT count(*) FROM visits").Scan(&count); err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(visitsResponse{Count: count})
}
Two things matter here beyond the handlers themselves. First, the app reads PORT from the environment and falls back to 8080, and it binds to 0.0.0.0:$PORT, not localhost:$PORT. Caddy runs in its own container and reaches this app over the Docker network by its container name, not through the loopback interface, so binding to localhost would mean Caddy can never reach it at all. Second, DATABASE_URL is read from the environment rather than hardcoded, so the exact same binary runs unchanged against a local database, a staging database or the production one on this server.
It’s worth naming what’s absent compared to an interpreted language. A Rails or Django deploy has to make sure the right Ruby or Python version is installed on the server and that the matching gems or packages are fetched and found at the paths the interpreter expects. None of that applies here. go build produces one binary with everything already linked in; the server doesn’t need Go installed at all, just the compiled file. That’s also why the Dockerfile in the next step has two stages: one to compile, one to just run the result.
Step 4: Write the Dockerfile
FROM golang:1.27.3-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server .
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=build /out/server /usr/local/bin/server
EXPOSE 8080
CMD ["server"]
The first stage uses golang:1.27.3-alpine to compile the binary. CGO_ENABLED=0 turns off cgo, so the resulting binary doesn’t link against the system’s C library, and GOOS=linux guarantees a Linux binary even if you’re building on a Mac. The second stage starts from a plain alpine:3.21, far smaller than the build image, and copies in only the compiled server binary. ca-certificates is installed so the binary can verify TLS certificates if DATABASE_URL ever points at a Postgres instance requiring SSL; pgx has no C dependencies once CGO_ENABLED=0 is set, so nothing else needs installing in this runtime stage.
Step 5: Write the Docker Compose file
Copy your app to the server (scp, rsync, or a git clone of your repository into /opt/myapp), create an .env file with a database password, then add compose.yml next to it:
POSTGRES_PASSWORD=a-long-random-password
services:
app:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgres://myapp:${POSTGRES_PASSWORD}@db:5432/myapp?sslmode=disable
PORT: "8080"
depends_on:
db:
condition: service_healthy
expose:
- "8080"
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: myapp
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 10
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
volumes:
db_data:
caddy_data:
caddy_config:
The app service reaches Postgres at the hostname db, Compose’s name for that service, not localhost. depends_on: condition: service_healthy keeps the app from starting before Postgres can actually accept connections, not just before its container exists. Only caddy publishes ports to the host, so Postgres and the app itself are never reachable from outside this server at all.
Check the file parses cleanly before going further:
docker compose config
You should see the fully resolved configuration printed with no error lines.
Step 6: Write the Caddyfile
myapp.example.com {
reverse_proxy app:8080
}
Replace myapp.example.com with the domain you pointed at the server in Step 1, and 8080 with whatever PORT you set in compose.yml if you change it. Caddy proxies every request to the app container on that port, and requests a free TLS certificate from Let’s Encrypt for that exact hostname, renewing it automatically. This only works if the domain already resolves to this server, which is why DNS came before Caddy in this guide. You can check the Caddyfile is well-formed with:
docker run --rm -v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile" caddy:2 caddy validate --config /etc/caddy/Caddyfile
You should see Valid configuration in the output.
Step 7: Boot the stack
From /opt/myapp:
docker compose up -d --build
This builds the image, starts Postgres, waits for it to report healthy, then starts the app, which connects to Postgres and creates its one table on first boot.
Check the stack:
docker compose ps
curl -I https://myapp.example.com/healthz
You should see all three services as Up (and db as healthy), and the curl should return HTTP/2 200 with no certificate warning. Hitting /visits a few times should return an increasing count:
curl https://myapp.example.com/visits
Step 8: Back up Postgres
A dump taken outside the data volume is what actually protects you if the server disappears. Add a backup script:
cat > /opt/myapp/backup.sh <<'EOF'
#!/bin/sh
cd /opt/myapp
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U myapp myapp | gzip > /opt/myapp/backups/$TIMESTAMP.sql.gz
find /opt/myapp/backups -name '*.sql.gz' -mtime +14 -delete
EOF
mkdir -p /opt/myapp/backups
chmod +x /opt/myapp/backup.sh
Schedule it with cron, daily at 3am:
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/myapp/backup.sh") | crontab -
Run it once by hand and check it worked:
/opt/myapp/backup.sh
ls -lh /opt/myapp/backups
You should see a .sql.gz file with today’s timestamp and a non-zero size. For anything beyond a personal project, copy these files off the server too, to another machine or an object store, so losing the server doesn’t also mean losing the backup.
Step 9: Ship an upgrade
Every later change follows the same shape: get the new code onto the server, rebuild the image, replace the running container.
git pull
docker compose build app
docker compose up -d app
docker compose up -d app only recreates the app container, not db or caddy, so Postgres keeps running and Caddy’s certificate state is untouched. There’s a short gap, a second or two, while the old container stops and the new one starts, since this setup runs a single app container.
Worth being explicit about one thing Rails and Django handle automatically and Go does not: there’s no single dominant convention for running schema migrations as part of a deploy. This guide’s one table is created with CREATE TABLE IF NOT EXISTS directly in main.go, fine for something this small, but a real app with evolving schema usually reaches for a dedicated tool. goose and golang-migrate are the two most common, run as an explicit step, either baked into the container’s startup command or run by hand before docker compose up -d app. Decide on one and be consistent, since nothing does it for you implicitly.
Check it landed:
docker compose logs --tail=50 app
curl -I https://myapp.example.com/healthz
You should see your app boot cleanly in the logs and the curl still returning 200 on the new version.
The shortcut: do it with Railyard
Everything above (the server, Docker, Postgres, Caddy, backups, a repeatable release) is also what Railyard automates for a Go app on Hetzner, without writing any of the files above by hand.
Railyard detects Go from go.mod, including a module that lives in a subfolder rather than the repository root, and builds it from the right place automatically. A known database driver import in your code, github.com/jackc/pgx, github.com/lib/pq, github.com/go-sql-driver/mysql or github.com/redis/go-redis, provisions the matching managed database for you and injects DATABASE_URL or REDIS_URL as an environment variable, with no addon to add by hand first, the same connection this guide wired up manually in Step 5. The one requirement carries over unchanged from the manual Dockerfile above: the binary has to listen on $PORT, on all interfaces, not just localhost.
Go has no single dominant job-queue framework the way Rails has Sidekiq or Solid Queue, so there’s nothing for Railyard to auto-detect there either. Instead, a worker process is one line in a Procfile-style process declaration:
web: ./server
worker: ./server -mode=worker
Both lines run the same built binary; your own main() reads a flag or environment variable to decide which loop to run. Each process type scales independently from the others.
Paste your repository’s URL into https://app.railyard.run/deploy?repo= and pick Hetzner as the provider, or go directly through the Go on Hetzner deploy path. See the Go docs for the exact detection rules. Railyard’s Free plan covers one server plus a database server and three apps with no card required, Pro is $19/month per team, and Team is $49/month per team.
Troubleshooting
Binary built locally won’t run on the server. An error like exec format error when the container starts usually means the binary was cross-compiled for the wrong architecture, built on an Apple Silicon Mac without GOARCH=amd64 and then copied straight onto an x86 server, or GOOS=linux was left off so the binary only runs on the machine that built it. Building inside the Dockerfile with CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build, as shown in Step 4, rather than copying a locally built binary in, avoids this since the build always targets the right platform.
Postgres connection refused. If the app logs show could not connect to server: Connection refused or similar, check that DATABASE_URL uses db as the hostname, not localhost or 127.0.0.1. Inside Compose’s network, db is the only address that resolves to the Postgres container; localhost means the app container itself, which has no database running in it.
Port already in use. If docker compose up fails with something binding to port 80 or 443, Ubuntu’s default Apache or Nginx may already be installed and running. Check with ss -tlnp | grep -E ':80|:443' and stop or remove whichever service owns them before Caddy tries to bind.
HTTPS not issuing. If docker compose logs caddy shows repeated certificate errors or NXDOMAIN, your DNS record from Step 1 either hasn’t propagated yet or is pointing at the wrong IP. Re-run dig +short myapp.example.com, confirm it matches the server, then restart Caddy with docker compose restart caddy once it does.
Out of memory under load. A CX23’s 4 GB of memory comfortably runs this stack at rest, but a goroutine leak, spawning one per request and never letting it exit, for example, can grow memory use steadily until the kernel starts killing processes. docker stats on the server shows whether the app container’s memory is climbing over time rather than settling after a burst of traffic; pprof’s heap profile is the next step if it is.
FAQ
Do I need a framework like gin or echo for a small app? No. The standard library’s net/http and http.ServeMux handle routing, and http.Handler and middleware patterns are plain Go, no framework required, for anything that isn’t dealing with dozens of routes or wanting built-in request validation and grouping.
How do I add a worker process? Add a second service to compose.yml built from the same image, running the same binary with a different flag or command, for example ./server -mode=worker instead of ./server. It reads the same DATABASE_URL, just without binding to any port.
Does the static binary approach actually matter for security? It removes a whole category of problem: no interpreter version with its own CVEs to patch on the server, and no package manager resolving transitive dependencies at deploy time. Vulnerabilities in your own Go dependencies still need tracking, same as any language, but the attack surface of “what’s installed besides my app” is close to zero.
What if the app has no database at all? Drop the db service from compose.yml entirely, remove DATABASE_URL from app’s environment, and the rest of the guide is unchanged: build the binary the same way, run it behind Caddy the same way, back up nothing because there’s nothing stateful to back up.
How do migrations work, since Go has no single convention? Unlike Rails’ db:prepare or Django’s migrate, nothing runs implicitly. Pick a tool, goose and golang-migrate are the two most widely used, and run it as an explicit step in your deploy process, either as a command in the container’s entrypoint or a manual step before docker compose up -d app. For a schema this simple, CREATE TABLE IF NOT EXISTS at startup, as this guide’s main.go does, is a reasonable stopgap, but it doesn’t handle altering an existing table, which is where a real migration tool earns its place.
That’s a full production setup for a Go service: a server, Docker Compose running the app and Postgres, Caddy handling HTTPS, a nightly backup, and a two-command release. Keep compose.yml, Caddyfile and the Dockerfile in your repository alongside the app, and every future deploy is the same git pull, build, restart you just ran in Step 9.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.