February 22, 2026

Building a Production-Grade URL Shortener in Go

Designing a URL shortener in Go with Chi, PostgreSQL, Base62 short codes, multi-stage Docker builds, AWS ECS Fargate, and Prometheus metrics.

Quick navigation

Project Overview

Tech Stack:

ComponentTechnology
LanguageGo 1.25
Web FrameworkChi Router v5
DatabasePostgreSQL 15 with migrations
ContainerizationMulti-stage Docker builds
DeploymentAWS ECS Fargate
ObservabilityPrometheus metrics + Zap structured logging

Repository: github.com/kaungmyathan22/golang-url-shortener


System Architecture

┌──────────────┐     ┌──────────────────┐     ┌─────────────────┐
│ HTTP Client  │ --> │ REST API         │ --> │ PostgreSQL DB   │
│ (Browser)    │     │ /api/urls        │     │ Links Table     │
└──────────────┘     └──────────────────┘     └─────────────────┘
       ↓                     ↓                         ↓
  GET /abc123        Base62 Encoding           Connection Pooling
  (Redirects)        UUID Generation           Transaction Safety

Core Flow:

  1. User submits long URL via POST /api/urls
  2. System generates unique short code using Base62 encoding
  3. Stores mapping in PostgreSQL with click tracking
  4. Returns short URL to user
  5. Redirects work via GET /{shortCode}

Development Timeline: 10 Days

Day 1-2: Project Foundation

Started with proper Go project structure:

mkdir golang-url-shortener && cd golang-url-shortener
go mod init github.com/kaungmyathan22/golang-url-shortener

# Directory structure
mkdir -p cmd/api
mkdir -p internal/{config,server,storage}
mkdir -p pkg/logger

Architecture Decision: Internal packages prevent external imports, enforcing clean boundaries.

cmd/api/              # Application entry point
internal/
  ├── config/         # Configuration management
  ├── server/         # HTTP handlers and routing
  └── storage/        # Data persistence layer
pkg/
  └── logger/         # Shared logging utilities

Day 3-4: Core API Implementation

Built the RESTful API with Chi router:

// internal/server/routes.go
func (s *Server) setupRoutes() {
    s.router.Post("/api/urls", s.handleCreateURL())
    s.router.Get("/api/urls/{id}", s.handleGetURL())
    s.router.Get("/{shortCode}", s.handleRedirect())
    s.router.Get("/api/health", s.handleHealth())
    s.router.Get("/metrics", promhttp.Handler().ServeHTTP)
}

Create URL Handler:

func (s *Server) handleCreateURL() http.HandlerFunc {
    type request struct {
        LongURL string `json:"long_url"`
    }

    type response struct {
        ShortURL  string `json:"short_url"`
        ShortCode string `json:"short_code"`
    }

    return func(w http.ResponseWriter, r *http.Request) {
        var req request
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            http.Error(w, "Invalid request", http.StatusBadRequest)
            return
        }

        link := &storage.Link{
            ID:        uuid.New().String(),
            ShortCode: encoding.Encode(uint64(time.Now().UnixNano())),
            LongURL:   req.LongURL,
            CreatedAt: time.Now(),
        }

        if err := s.store.CreateLink(r.Context(), link); err != nil {
            http.Error(w, "Failed to create link", http.StatusInternalServerError)
            return
        }

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(response{
            ShortURL:  s.config.BaseURL + "/" + link.ShortCode,
            ShortCode: link.ShortCode,
        })
    }
}

Key Design Choice: Base62 encoding (using 0-9, a-z, A-Z) generates URL-safe short codes. Timestamp-based ensures uniqueness without database lookups.


Day 5-7: PostgreSQL Integration (The Hard Part)

Initial implementation used in-memory storage. Big mistake: All data vanished on restart.

Migration to PostgreSQL:

-- migrations/001_create_links_table.up.sql
CREATE TABLE IF NOT EXISTS links (
    id VARCHAR(36) PRIMARY KEY,
    short_code VARCHAR(10) UNIQUE NOT NULL,
    long_url TEXT NOT NULL,
    user_id VARCHAR(36),
    clicks BIGINT DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_links_short_code ON links(short_code);
CREATE INDEX idx_links_user_id ON links(user_id);

Repository Implementation:

// internal/storage/postgres.go
type PostgresRepository struct {
    db *sql.DB
}

func (r *PostgresRepository) CreateLink(ctx context.Context, link *Link) error {
    query := `
        INSERT INTO links (id, short_code, long_url, user_id, created_at)
        VALUES ($1, $2, $3, $4, $5)
    `

    _, err := r.db.ExecContext(
        ctx,
        query,
        link.ID,
        link.ShortCode,
        link.LongURL,
        link.UserID,
        link.CreatedAt,
    )

    return err
}

func (r *PostgresRepository) GetLinkByShortCode(ctx context.Context, shortCode string) (*Link, error) {
    query := `
        SELECT id, short_code, long_url, user_id, clicks, created_at, updated_at
        FROM links
        WHERE short_code = $1
    `

    var link Link
    err := r.db.QueryRowContext(ctx, query, shortCode).Scan(
        &link.ID,
        &link.ShortCode,
        &link.LongURL,
        &link.UserID,
        &link.Clicks,
        &link.CreatedAt,
        &link.UpdatedAt,
    )

    if err == sql.ErrNoRows {
        return nil, ErrNotFound
    }

    return &link, err
}

Critical Lesson: Column count in SELECT must match Scan() parameters exactly. Mismatches cause silent failures.

Connection Pool Configuration:

func NewPostgresDB(dsn string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, err
    }

    // Connection pool settings
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(5)
    db.SetConnMaxLifetime(5 * time.Minute)
    db.SetConnMaxIdleTime(1 * time.Minute)

    // Test connection
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := db.PingContext(ctx); err != nil {
        return nil, err
    }

    return db, nil
}

Day 8-9: Observability Layer

Production systems need visibility. Added structured logging and metrics.

Structured Logging with Zap:

// pkg/logger/logger.go
func New(env string) (*zap.Logger, error) {
    var config zap.Config

    if env == "production" {
        config = zap.NewProductionConfig()
    } else {
        config = zap.NewDevelopmentConfig()
    }

    config.EncoderConfig.TimeKey = "timestamp"
    config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder

    return config.Build()
}

Sample Log Output:

{
  "level": "info",
  "timestamp": "2026-04-07T10:45:40.047Z",
  "caller": "server/server.go:93",
  "msg": "request completed",
  "method": "POST",
  "path": "/api/urls",
  "status": 200,
  "latency": "0.001s",
  "client_ip": "192.168.1.100"
}

Prometheus Metrics:

var (
    httpRequestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP request duration in seconds",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "path", "status"},
    )

    totalRequests = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total number of HTTP requests",
        },
        []string{"method", "path", "status"},
    )

    activeConnections = promauto.NewGauge(
        prometheus.GaugeOpts{
            Name: "db_active_connections",
            Help: "Number of active database connections",
        },
    )
)

Middleware for Automatic Metrics:

func (s *Server) metricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
        next.ServeHTTP(wrapped, r)

        duration := time.Since(start).Seconds()
        status := strconv.Itoa(wrapped.statusCode)

        httpRequestDuration.WithLabelValues(r.Method, r.URL.Path, status).Observe(duration)
        totalRequests.WithLabelValues(r.Method, r.URL.Path, status).Inc()

        s.logger.Info("request completed",
            zap.String("method", r.Method),
            zap.String("path", r.URL.Path),
            zap.Int("status", wrapped.statusCode),
            zap.Duration("latency", time.Since(start)),
        )
    })
}

Day 10: Production Deployment

Multi-Stage Docker Build:

# Stage 1: Build
FROM golang:1.25-alpine AS builder

WORKDIR /app

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Build binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api

# Stage 2: Runtime
FROM alpine:3.21

# Security: non-root user
RUN adduser -D -u 1000 appuser

# Install CA certificates for HTTPS
RUN apk --no-cache add ca-certificates

WORKDIR /home/appuser

# Copy binary from builder
COPY --from=builder /app/main .
COPY --from=builder /app/migrations ./migrations

# Switch to non-root
USER appuser

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1

CMD ["./main"]

AWS ECS Fargate Deployment:

{
  "family": "url-shortener",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "containerDefinitions": [
    {
      "name": "url-shortener",
      "image": "your-ecr-repo/url-shortener:latest",
      "portMappings": [
        {
          "containerPort": 8080,
          "protocol": "tcp"
        }
      ],
      "environment": [
        {
          "name": "DATABASE_URL",
          "value": "postgres://user:pass@rds-endpoint:5432/urlshortener"
        },
        {
          "name": "PORT",
          "value": "8080"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/url-shortener",
          "awslogs-region": "ap-southeast-1",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "healthCheck": {
        "command": ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 60
      }
    }
  ]
}

Cost Management: Stopped ECS tasks after testing to avoid charges. Total AWS cost: $0.00 (within free tier).


Key Technical Lessons

1. Connection Pool Management

Problem: Premature db.Close() in handler caused intermittent failures.

Solution: Close database only on application shutdown:

func main() {
    db, err := storage.NewPostgresDB(config.DatabaseURL)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close() // Only here, not in handlers!

    // ... rest of application
}

2. Chi Router Route Order Matters

Problem: Generic route /{shortCode} matched before specific routes like /api/urls.

Solution: Register specific routes first:

// CORRECT ORDER
router.Post("/api/urls", handler)      // Specific first
router.Get("/api/urls/{id}", handler)  // Specific first
router.Get("/{shortCode}", handler)    // Generic last

3. Query Column Alignment

Problem: Adding updated_at to table but not updating SELECT query caused Scan() errors.

Solution: Always verify column count matches:

// SELECT columns must match Scan() parameters
SELECT id, short_code, long_url, user_id, clicks, created_at, updated_at  -- 7 columns
FROM links

// Scan must have 7 targets
Scan(&id, &shortCode, &longURL, &userID, &clicks, &createdAt, &updatedAt)

4. Context Timeouts for Database Operations

func (r *PostgresRepository) CreateLink(ctx context.Context, link *Link) error {
    // Create timeout context if none exists
    if _, ok := ctx.Deadline(); !ok {
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
        defer cancel()
    }

    _, err := r.db.ExecContext(ctx, query, args...)
    return err
}

5. Graceful Shutdown

func main() {
    srv := &http.Server{
        Addr:    ":8080",
        Handler: router,
    }

    // Start server in goroutine
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    // Graceful shutdown with 30-second timeout
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal("Server forced to shutdown:", err)
    }

    log.Println("Server exited gracefully")
}

Performance Metrics

Load Testing Results (using wrk):

wrk -t4 -c100 -d30s http://localhost:8080/api/urls
MetricValue
Requests/sec12,450
Latency (avg)8.2ms
Latency (p95)23.1ms
Latency (p99)45.8ms
Success Rate100%

Database Performance:

  • Connection pool utilization: ~40% under load
  • Query execution time (avg): 2.1ms
  • Index hit rate: 99.8%

Interview Talking Points

When asked "Tell me about a recent project":

"I built a production-grade URL shortener microservice in Go while working full-time. The system uses PostgreSQL for persistence, Docker for containerization, and was deployed to AWS ECS Fargate. I implemented proper observability with structured logging and Prometheus metrics, achieving p95 latency under 25ms. Key technical challenges included managing database connection pools, implementing graceful shutdowns, and optimizing route matching in the Chi router."

Follow-up answers prepared:

  • Scaling strategy: Add Redis caching layer for hot links, horizontal scaling via load balancer
  • Security concerns: Rate limiting, URL validation, HTTPS enforcement, SQL injection prevention via parameterized queries
  • Monitoring: Prometheus + Grafana dashboards tracking request rates, error rates, database connections

Resume Bullet

• Architected and deployed production URL shortener microservice in Go to AWS ECS Fargate,
  handling 12K+ req/sec with p95 latency <25ms using PostgreSQL persistence, multi-stage
  Docker builds, and Prometheus observability.

• Implemented Base62 encoding, connection pooling, graceful shutdown, and comprehensive
  error handling. Repository: github.com/kaungmyathan22/golang-url-shortener

Resources That Helped


What's Next?

Next project covered building an async job queue worker with Redis and goroutine worker pools. Check out that project here.

Future Enhancements for URL Shortener:

  • Custom short codes (user-defined aliases)
  • Analytics dashboard (clicks over time, geographic distribution)
  • QR code generation
  • Link expiration dates
  • JWT authentication for multi-user support
  • Redis caching for hot links

Conclusion

Building production systems while working full-time taught me:

  1. Consistency beats intensity — 75 minutes daily > 4-hour weekend marathons
  2. Production-grade means boring problems solved well — Connection pools, graceful shutdowns, proper logging
  3. Documentation is your second product — READMEs sell projects to recruiters
  4. Cost awareness is professional — Monitor AWS usage, stop resources when not needed
  5. Shipped code > perfect code — Done is better than perfect

This project opened doors for senior backend interviews. Not because it's flashy—because it demonstrates production thinking.


Built with discipline over 10 evenings while working full time.

👉 View Full Source Code


Questions or feedback? Reach out on LinkedIn or GitHub.