Build a URL Shortener with Go and PostgreSQL
Build and deploy a self-hosted URL shortener with Go, PostgreSQL and Redis, serving millions of redirects a day on a single VPS.
Build a production-ready URL shortener with Go, PostgreSQL and Redis, deployed on a single VPS. Fast path: a Redis read per redirect, PostgreSQL as the source of truth.
[Live Demo] [GitHub] [Deploy]
What we’re building
A link shortener that resolves redirects with a zero-query hot path: every redirect hits only Redis. PostgreSQL is only queried on a cache miss.
flowchart LR
User -->|short URL| Caddy
Caddy --> Go
Go --> Redis[(Redis cache)]
Go --> Postgres[(PostgreSQL)]
Redis -.on miss.-> Postgres
What you’ll learn
- How to design a code-first short-link system (no collision-prone random strings reused blindly)
- How to serve 1M+ redirects/day with a cache in front of the database
- How to deploy Go + PostgreSQL + Redis on a VPS with Caddy and automatic HTTPS
Prerequisites
- Go 1.23+
- PostgreSQL 17
- Redis 7
- A VPS (any provider) with 2 GB RAM
- Docker (for local Postgres/Redis and the final deploy)
Project structure
urlshort/
├── cmd/server/main.go # entry point: wires everything and starts HTTP
├── internal/handler/ # HTTP handlers (redirect + shorten)
│ ├── redirect.go
│ └── shorten.go
├── internal/store/ # data access (PostgreSQL + Redis)
│ ├── postgres.go
│ └── cache.go
├── migrations/0001_init.sql # table schema
├── .env # your local configuration (not committed)
├── go.mod
├── Dockerfile
└── docker-compose.yml # runs postgres + redis + app + caddy together
1. Create the project
mkdir urlshort && cd urlshort
go mod init github.com/you/urlshort # replace `you` with your GitHub username
go get github.com/jackc/pgx/v5 github.com/redis/go-redis/v9
pgx/v5 talks to PostgreSQL (with connection pooling built in); go-redis/v9 talks to Redis. Both are the current standard libraries for Go.
2. Set up PostgreSQL and Redis locally
docker run -d --name pg --rm \
-p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:17
docker run -d --name redis --rm \
-p 6379:6379 redis:7
-p 5432:5432/-p 6379:6379map the container ports to your machine so the Go app can reach them atlocalhost.--rmdeletes the container on stop; use thedocker-compose.ymllater for the permanent setup.- Want Postgres on a different port? Change the left side (
5432:→5433:) and updateDATABASE_URL.
3. Create the schema
migrations/0001_init.sql:
CREATE TABLE IF NOT EXISTS links (
id BIGSERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
slugis the short code clients put in the URL (https://short/xY9z).UNIQUEmeans two links can never share one code — the database enforces this, and our insert retries on the rare collision.BIGSERIALgives each row a private, auto-incrementing numeric id (useful later for analytics).
Apply it:
psql "postgres://postgres:dev@localhost:5432/postgres" -f migrations/0001_init.sql
4. Environment variables
Create this file at the project root — it is not committed to git:
.env:
DATABASE_URL=postgres://postgres:dev@localhost:5432/postgres
REDIS_ADDR=localhost:6379
PORT=8080
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string. On the VPS it points at the postgres service instead of localhost. |
REDIS_ADDR |
yes | host:port of Redis. Same trick: becomes redis:6379 inside Docker. |
PORT |
no | HTTP port the Go server listens on. Default 8080. Change to 80 if you want it public directly. |
5. Implement the PostgreSQL store
internal/store/postgres.go:
package store
import (
"context"
"errors"
"fmt"
"math/rand"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Link is one row of the links table.
type Link struct {
Slug string
URL string
}
// Postgres wraps the connection pool.
type Postgres struct {
DB *pgxpool.Pool
}
// NewPostgres opens a pooled connection and verifies it with a ping.
func NewPostgres(ctx context.Context, dsn string) (*Postgres, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("connect to postgres: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping postgres: %w", err)
}
return &Postgres{DB: pool}, nil
}
// CreateLink inserts a new link. The slug is generated in code (never
// user-supplied) and the insert retries if it collides (Postgres error 23505).
func (s *Postgres) CreateLink(ctx context.Context, url string) (Link, error) {
for attempt := 0; attempt < 3; attempt++ {
slug := encodeBase62(rand.Int63())
_, err := s.DB.Exec(ctx,
"INSERT INTO links (slug, url) VALUES ($1, $2)", slug, url)
if err == nil {
return Link{Slug: slug, URL: url}, nil
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation on slug
continue // unlucky collision — try a new random slug
}
return Link{}, err
}
return Link{}, fmt.Errorf("could not allocate a unique slug")
}
// Resolve looks up the target URL for a slug.
func (s *Postgres) Resolve(ctx context.Context, slug string) (string, error) {
var url string
err := s.DB.QueryRow(ctx,
"SELECT url FROM links WHERE slug = $1", slug).Scan(&url)
return url, err
}
// encodeBase62 turns a number into a short, URL-safe code.
const base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
func encodeBase62(n int64) string {
if n <= 0 {
return "0"
}
var b [11]byte
i := len(b)
for n > 0 {
i--
b[i] = base62Chars[n%62]
n /= 62
}
return strings.TrimSpace(string(b[i:]))
}
What this does, line by line:
pgxpool.New+Pingconnect once at startup and fail fast if PostgreSQL is unreachable — you’ll see the real error immediately instead of at the first request.- The
for attemptloop implements the collision retry from the “Common problems” section: with ~62^11 possible slugs collisions are rare, but when they happen the database error (23505) is caught and the insert simply tries again. - Every query uses
$1/$2placeholders — user input can never be injected into SQL. - Change the slug alphabet or length? Edit
base62Charsor the loop bound inencodeBase62.
6. Implement the Redis cache
internal/store/cache.go:
package store
import (
"context"
"errors"
"time"
"github.com/redis/go-redis/v9"
)
// Cache wraps the Redis client.
type Cache struct {
RDB *redis.Client
}
// NewCache builds a client. No I/O happens here; the first command verifies connectivity.
func NewCache(addr string) *Cache {
return &Cache{RDB: redis.NewClient(&redis.Options{Addr: addr})}
}
// Get returns (url, true, nil) on a cache hit, (\"\", false, nil) on a miss,
// and (\"\", false, err) on a Redis failure — callers must NOT treat errors as misses.
func (c *Cache) Get(ctx context.Context, slug string) (string, bool, error) {
url, err := c.RDB.Get(ctx, "slug:"+slug).Result()
if errors.Is(err, redis.Nil) {
return "", false, nil // key does not exist -> clean miss
}
if err != nil {
return "", false, err
}
return url, true, nil
}
// Set stores the mapping for 24 hours.
func (c *Cache) Set(ctx context.Context, slug, url string) error {
return c.RDB.Set(ctx, "slug:"+slug, url, 24*time.Hour).Err()
}
- Keys are namespaced as
slug:<code>so a future cache (click counters, etc.) does not collide with redirect entries. redis.Nilis how go-redis signals “key not found” — it is an expected case, not an error, so the handler treats it as a cache miss rather than a failure.- TTL 24h means Redis never grows without bound; dead links evict themselves.
- Want a shorter cache? Change
24*time.Houron theSetline.
7. Implement the HTTP handlers
internal/handler/redirect.go — the performance-critical path:
package handler
import (
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"github.com/you/urlshort/internal/store"
)
// Handler owns the dependencies the HTTP handlers need.
type Handler struct {
store *store.Postgres
cache *store.Cache
}
// NewHandler wires dependencies. main.go calls this once at startup.
func NewHandler(db *store.Postgres, cache *store.Cache) *Handler {
return &Handler{store: db, cache: cache}
}
// Redirect resolves a short link: cache first, database on miss.
func (h *Handler) Redirect(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
// Hot path: one Redis GET, zero SQL.
if url, hit, err := h.cache.Get(r.Context(), slug); err == nil && hit {
http.Redirect(w, r, url, http.StatusMovedPermanently)
return
}
// Miss: fall back to PostgreSQL.
url, err := h.store.Resolve(r.Context(), slug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r) // slug does not exist -> 404
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = h.cache.Set(r.Context(), slug, url) // populate the cache for the next request
http.Redirect(w, r, url, http.StatusMovedPermanently)
}
internal/handler/shorten.go — validation + a real error response:
package handler
import (
"encoding/json"
"net/http"
"net/url"
)
type shortenRequest struct {
URL string `json:"url"`
}
func (h *Handler) Shorten(w http.ResponseWriter, r *http.Request) {
var req shortenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest)
return
}
u, err := url.Parse(req.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
http.Error(w, `{"error":"a valid http(s) URL is required"}`, http.StatusBadRequest)
return
}
link, err := h.store.CreateLink(r.Context(), req.URL)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) // 201
json.NewEncoder(w).Encode(map[string]string{"slug": link.Slug})
}
Why this is important:
url.Parserejects garbage and enforceshttp/httpswith a real host — you can’t create a redirect tojavascript:alert(1).- Errors are explicit:
400for bad input,500for failures,404for unknown slugs. A client can now tell what went wrong. r.PathValue("slug")reads the{slug}path segment (Go 1.22+ routing, wired inmain.gobelow).
8. Wire up the server
cmd/server/main.go:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/you/urlshort/internal/handler"
"github.com/you/urlshort/internal/store"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := store.NewPostgres(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("database: %v", err)
}
cache := store.NewCache(os.Getenv("REDIS_ADDR"))
h := handler.NewHandler(db, cache)
mux := http.NewServeMux()
mux.HandleFunc("GET /{slug}", h.Redirect)
mux.HandleFunc("POST /api/shorten", h.Shorten)
mux.HandleFunc("GET /api/healthz",
func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) })
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("listening on :%s", port)
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}
signal.NotifyContextmakesCtrl+Cshut the server down cleanly instead of killing the connection pool mid-query.GET /{slug}andPOST /api/shortenare Go 1.22+ method+pattern routing — note the{slug}must matchr.PathValue("slug")./api/healthzis your uptime-monitoring probe (used by your monitoring tool of choice).
9. Run locally
set -a && source .env && set +a # load DATABASE_URL, REDIS_ADDR, PORT into the shell
go run ./cmd/server
In a second terminal, test the full loop:
curl -s -X POST http://localhost:8080/api/shorten \
-H 'content-type: application/json' \
-d '{"url":"https://example.com"}'
# {"slug":"a1Bx2y"} <- the exact code is random
curl -i http://localhost:8080/a1Bx2y
# HTTP/1.1 301 Moved Permanently
# Location: https://example.com
Repeat the second curl a few times: only the first request hits PostgreSQL; the rest are served from Redis.
10. Dockerize
Dockerfile:
FROM golang:1.23-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server
FROM alpine:3.20
COPY --from=build /server /server
EXPOSE 8080
CMD ["/server"]
- Multi-stage build: the heavy Go toolchain compiles everything, then only the single static binary (
/server) is copied into the tiny final image. CGO_ENABLED=0keeps the binary fully static — it runs on any base image, no shared libraries needed.
docker-compose.yml — runs the whole stack (also used locally, replacing the two docker run commands in step 2):
services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: dev
POSTGRES_DB: urlshort
volumes:
- ./migrations/0001_init.sql:/docker-entrypoint-initdb.d/00_init.sql:ro
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
redis:
image: redis:7
command: ["redis-server", "--appendonly", "yes"] # AOF persistence, see checklist
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
app:
build: .
environment:
DATABASE_URL: postgres://postgres:dev@postgres:5432/urlshort
REDIS_ADDR: redis:6379
PORT: "8080"
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
restart: unless-stopped
caddy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on: [app]
restart: unless-stopped
volumes:
pgdata:
caddy_data:
- The
postgresinitdb mount runsmigrations/0001_init.sqlthe first time the volume is created — schema in place beforeappstarts. healthcheck+depends_on.conditionmeans the app waits until Postgres and Redis actually answer before booting. Without this you get “connection refused” races on every cold start.- Caddy runs in the same Docker network, so
reverse_proxy app:8080reaches the Go server by service name.
11. Deploy to a VPS
On the VPS (docker installed): copy the project, then
docker compose up -d
Caddyfile (at the project root):
YOUR-DOMAIN.com {
reverse_proxy app:8080
}
Replace YOUR-DOMAIN.com with your real domain and point its DNS A record at the VPS IP. Caddy automatically obtains and renews the HTTPS certificate — no manual certbot.
Checking it works:
curl -s https://YOUR-DOMAIN.com/api/healthz # -> ok
12. Backups
Daily PostgreSQL dump to object storage, from a cron job on the VPS:
0 2 * * * pg_dump "$DATABASE_URL" | gzip > /backups/db-$(date +\%F).sql.gz
Test the restore path at least once: zcat backup.sql.gz | psql "$DATABASE_URL" into a scratch database — a backup you can’t restore is not a backup.
Cost (single VPS)
| Service | Monthly cost |
|---|---|
| VPS 2 GB / 40 GB (Hetzner) | $4.49 |
| Domain | ~$1.00 |
| Object storage (backups) | $0.50 |
| Total | ≈ $6/month |
Assumptions (shown so you can recompute for your scale): 1M redirects/day, ~100 GB monthly bandwidth, 5 GB backups. At that volume Redis + a 2 GB VPS is nowhere near a bottleneck; PostgreSQL does a handful of writes per second.
Production checklist
- HTTPS via Caddy (automatic — verify the cert shows in the browser)
- Redis AOF persistence on (already set in docker-compose
command) - Daily
pg_dump+ a tested restore drill - Rate limit
/api/shorten(e.g. Caddyrate_limitdirective or a Go limiter) - Uptime monitoring on
/api/healthz - IPv6 DNS record + firewall for SSH and 80/443 only
Common problems
- 301 responses cached by browsers: browsers cache 301s permanently. While testing, change
StatusMovedPermanentlytohttp.StatusFound(302) inredirect.go, or always use a fresh slug per target. - Redis miss storm on restart: the cache is cold until re-populated; optional warmup queries the 1000 most recent links on boot. Not needed at the scale in this guide.
- Base62 collisions: handled in
CreateLinkby retrying on error23505. If you see them, you have billions of links — congratulations. connection refusedon firstdocker compose up: waited fordepends_onwithoutcondition: service_healthy— the compose file above includes the healthchecks that prevent this.
Improvements
- Click analytics:
INCR slug:<code>:viewsin the redirect handler, flush to PostgreSQL hourly - Custom aliases: a
POST /api/shorten/customwith a user-supplied slug (validate with a strict regex, e.g.^[a-zA-Z0-9]{3,32}$) - API keys with per-key rate limits for programmatic shortening
Conclusion
A cache-first architecture with PostgreSQL as source of truth handles millions of redirects on a $6/month VPS. The whole system is one binary, two containers it talks to, and a Caddyfile — reproducible from this guide, and cheap enough to forget about.