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:6379 map the container ports to your machine so the Go app can reach them at localhost.
  • --rm deletes the container on stop; use the docker-compose.yml later for the permanent setup.
  • Want Postgres on a different port? Change the left side (5432:5433:) and update DATABASE_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()
);
  • slug is the short code clients put in the URL (https://short/xY9z). UNIQUE means two links can never share one code — the database enforces this, and our insert retries on the rare collision.
  • BIGSERIAL gives 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 + Ping connect 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 attempt loop 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/$2 placeholders — user input can never be injected into SQL.
  • Change the slug alphabet or length? Edit base62Chars or the loop bound in encodeBase62.

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.Nil is 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.Hour on the Set line.

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.Parse rejects garbage and enforces http/https with a real host — you can’t create a redirect to javascript:alert(1).
  • Errors are explicit: 400 for bad input, 500 for failures, 404 for unknown slugs. A client can now tell what went wrong.
  • r.PathValue("slug") reads the {slug} path segment (Go 1.22+ routing, wired in main.go below).

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.NotifyContext makes Ctrl+C shut the server down cleanly instead of killing the connection pool mid-query.
  • GET /{slug} and POST /api/shorten are Go 1.22+ method+pattern routing — note the {slug} must match r.PathValue("slug").
  • /api/healthz is 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=0 keeps 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 postgres initdb mount runs migrations/0001_init.sql the first time the volume is created — schema in place before app starts.
  • healthcheck + depends_on.condition means 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:8080 reaches 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. Caddy rate_limit directive 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 StatusMovedPermanently to http.StatusFound (302) in redirect.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 CreateLink by retrying on error 23505. If you see them, you have billions of links — congratulations.
  • connection refused on first docker compose up: waited for depends_on without condition: service_healthy — the compose file above includes the healthchecks that prevent this.

Improvements

  • Click analytics: INCR slug:<code>:views in the redirect handler, flush to PostgreSQL hourly
  • Custom aliases: a POST /api/shorten/custom with 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.

Related guides