Backend development

Building an Internal API Gateway with Docker and Go from Scratch: Routing, Load Balancing, and Circuit Breaker

Ruslan Ismailov Published 14 min read
B

Why Build Your Own API Gateway and When It Makes Sense

Off-the-shelf solutions — Kong, Traefik, AWS API Gateway — cover most use cases. But there are situations where building your own API Gateway in Go is a justified choice:

  • You need full control over routing and load balancing logic without the overhead of someone else's code.
  • Performance requirements are so specific that general-purpose solutions fall short.
  • The team wants a deep understanding of how the gateway works internally, rather than managing a black box.
  • The infrastructure is isolated and third-party dependencies are not allowed.
  • You need custom circuit breaker logic or routing based on business rules.

If none of these conditions apply — use a ready-made solution. Otherwise, keep reading.

Solution Architecture: Components and Interaction Diagram

Our gateway consists of the following components:

  • Config Loader — loads and hot-reloads route configuration from a YAML file without restarting the process.
  • Router — matches incoming HTTP requests to routes by prefix/path.
  • Load Balancer — selects a backend from the pool (round-robin or weighted round-robin).
  • Circuit Breaker — tracks backend errors and temporarily removes a backend from rotation.
  • Proxy — forwards the request to the selected backend and returns the response to the client.
  • Metrics Collector — gathers statistics: RPS, latency, errors.

Interaction diagram (text description):

Client → [API Gateway: Router → Load Balancer → Circuit Breaker → Proxy] → Backend Service A / B / C → Response → Client

All components run inside a single Go process deployed in a Docker container. Backends are separate microservices in their own containers, connected via Docker Compose on a shared network.

Implementing Request Routing in Go with Dynamic Configuration

Let's start by defining the configuration in YAML:

routes:
  - prefix: /api/users
    backends:
      - url: http://users-service-1:8080
        weight: 2
      - url: http://users-service-2:8080
        weight: 1
  - prefix: /api/orders
    backends:
      - url: http://orders-service:8081
        weight: 1

Data structures for configuration:

package config

import (
    "os"
    "sync"
    "time"

    "gopkg.in/yaml.v3"
)

type Backend struct {
    URL    string `yaml:"url"`
    Weight int    `yaml:"weight"`
}

type Route struct {
    Prefix   string    `yaml:"prefix"`
    Backends []Backend `yaml:"backends"`
}

type Config struct {
    Routes []Route `yaml:"routes"`
}

type DynamicConfig struct {
    mu      sync.RWMutex
    current *Config
    path    string
}

func NewDynamicConfig(path string) (*DynamicConfig, error) {
    dc := &DynamicConfig{path: path}
    if err := dc.reload(); err != nil {
        return nil, err
    }
    go dc.watch()
    return dc, nil
}

func (dc *DynamicConfig) reload() error {
    data, err := os.ReadFile(dc.path)
    if err != nil {
        return err
    }
    var cfg Config
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return err
    }
    dc.mu.Lock()
    dc.current = &cfg
    dc.mu.Unlock()
    return nil
}

func (dc *DynamicConfig) watch() {
    ticker := time.NewTicker(10 * time.Second)
    for range ticker.C {
        _ = dc.reload()
    }
}

func (dc *DynamicConfig) Get() *Config {
    dc.mu.RLock()
    defer dc.mu.RUnlock()
    return dc.current
}

The router finds a route using longest-prefix match:

package router

import (
    "net/http"
    "sort"
    "strings"

    "gateway/config"
)

type Router struct {
    cfg *config.DynamicConfig
}

func New(cfg *config.DynamicConfig) *Router {
    return &Router{cfg: cfg}
}

func (r *Router) Match(req *http.Request) (*config.Route, bool) {
    routes := r.cfg.Get().Routes
    // sort by descending prefix length for longest-match
    sort.Slice(routes, func(i, j int) bool {
        return len(routes[i].Prefix) > len(routes[j].Prefix)
    })
    for _, route := range routes {
        if strings.HasPrefix(req.URL.Path, route.Prefix) {
            rv := route
            return &rv, true
        }
    }
    return nil, false
}

Load Balancing: Round-Robin and Weighted Round-Robin from Scratch

Round-robin is the simplest algorithm: each request goes to the next backend in sequence.

package balancer

import (
    "sync/atomic"

    "gateway/config"
)

type RoundRobin struct {
    counter uint64
}

func (rr *RoundRobin) Pick(backends []config.Backend) config.Backend {
    n := uint64(len(backends))
    idx := atomic.AddUint64(&rr.counter, 1) % n
    return backends[idx]
}

Weighted round-robin — backends with a higher weight receive proportionally more requests. We implement this by expanding the backend list:

package balancer

import (
    "sync/atomic"

    "gateway/config"
)

type WeightedRoundRobin struct {
    counter  uint64
    expanded []config.Backend
}

func NewWeightedRoundRobin(backends []config.Backend) *WeightedRoundRobin {
    var expanded []config.Backend
    for _, b := range backends {
        w := b.Weight
        if w <= 0 {
            w = 1
        }
        for i := 0; i < w; i++ {
            expanded = append(expanded, b)
        }
    }
    return &WeightedRoundRobin{expanded: expanded}
}

func (w *WeightedRoundRobin) Pick() config.Backend {
    n := uint64(len(w.expanded))
    idx := atomic.AddUint64(&w.counter, 1) % n
    return w.expanded[idx]
}

When the configuration is reloaded, the balancer is recreated with a new backend pool — atomically via sync/atomic and sync.RWMutex.

Implementing the Circuit Breaker Pattern in Go

The Circuit Breaker tracks the number of consecutive errors and transitions a backend to the OPEN state, temporarily removing it from rotation. After a specified timeout, a recovery attempt is made (the HALF-OPEN state).

package breaker

import (
    "sync"
    "time"
)

type State int

const (
    StateClosed   State = iota // normal operation
    StateOpen                  // backend unavailable
    StateHalfOpen              // checking recovery
)

type CircuitBreaker struct {
    mu           sync.Mutex
    state        State
    failures     int
    maxFailures  int
    timeout      time.Duration
    lastFailure  time.Time
}

func New(maxFailures int, timeout time.Duration) *CircuitBreaker {
    return &CircuitBreaker{
        maxFailures: maxFailures,
        timeout:     timeout,
        state:       StateClosed,
    }
}

func (cb *CircuitBreaker) Allow() bool {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    switch cb.state {
    case StateClosed:
        return true
    case StateOpen:
        if time.Since(cb.lastFailure) > cb.timeout {
            cb.state = StateHalfOpen
            return true
        }
        return false
    case StateHalfOpen:
        return true
    }
    return false
}

func (cb *CircuitBreaker) RecordSuccess() {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    cb.failures = 0
    cb.state = StateClosed
}

func (cb *CircuitBreaker) RecordFailure() {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    cb.failures++
    cb.lastFailure = time.Now()
    if cb.failures >= cb.maxFailures {
        cb.state = StateOpen
    }
}

Each backend gets its own CircuitBreaker instance. Before proxying, we call Allow() — if it returns false, we skip that backend and pick the next one from the pool.

Integration with REST API Backends and Request Proxying

The main handler brings all components together:

package proxy

import (
    "io"
    "net/http"
    "net/url"
    "time"

    "gateway/balancer"
    "gateway/breaker"
    "gateway/config"
    "gateway/router"
)

type Handler struct {
    router   *router.Router
    breakers map[string]*breaker.CircuitBreaker
    client   *http.Client
}

func NewHandler(r *router.Router) *Handler {
    return &Handler{
        router:   r,
        breakers: make(map[string]*breaker.CircuitBreaker),
        client: &http.Client{
            Timeout: 10 * time.Second,
        },
    }
}

func (h *Handler) getBreaker(backendURL string) *breaker.CircuitBreaker {
    if cb, ok := h.breakers[backendURL]; ok {
        return cb
    }
    cb := breaker.New(5, 30*time.Second)
    h.breakers[backendURL] = cb
    return cb
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    route, ok := h.router.Match(r)
    if !ok {
        http.Error(w, "no route found", http.StatusNotFound)
        return
    }

    wrr := balancer.NewWeightedRoundRobin(route.Backends)
    var lastErr error

    for attempt := 0; attempt < len(route.Backends); attempt++ {
        backend := wrr.Pick()
        cb := h.getBreaker(backend.URL)

        if !cb.Allow() {
            continue
        }

        targetURL, err := url.Parse(backend.URL)
        if err != nil {
            continue
        }
        targetURL.Path = r.URL.Path
        targetURL.RawQuery = r.URL.RawQuery

        req, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL.String(), r.Body)
        if err != nil {
            lastErr = err
            cb.RecordFailure()
            continue
        }
        req.Header = r.Header.Clone()

        resp, err := h.client.Do(req)
        if err != nil {
            lastErr = err
            cb.RecordFailure()
            continue
        }
        defer resp.Body.Close()

        if resp.StatusCode >= 500 {
            cb.RecordFailure()
            continue
        }

        cb.RecordSuccess()
        for k, v := range resp.Header {
            w.Header()[k] = v
        }
        w.WriteHeader(resp.StatusCode)
        io.Copy(w, resp.Body)
        return
    }

    if lastErr != nil {
        http.Error(w, "all backends unavailable: "+lastErr.Error(), http.StatusBadGateway)
    } else {
        http.Error(w, "all backends unavailable", http.StatusBadGateway)
    }
}

Containerizing the Gateway with Docker and Orchestrating via Docker Compose

The gateway Dockerfile uses a multi-stage build:

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o gateway ./cmd/gateway

FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/gateway .
COPY config.yaml .
EXPOSE 8000
CMD ["./gateway"]

Docker Compose connects the gateway and backends on a shared network:

version: "3.9"

services:
  gateway:
    build: ./gateway
    ports:
      - "8000:8000"
    volumes:
      - ./config.yaml:/app/config.yaml
    networks:
      - internal
    depends_on:
      - users-service-1
      - users-service-2
      - orders-service

  users-service-1:
    image: mycompany/users-service:latest
    networks:
      - internal
    environment:
      - PORT=8080

  users-service-2:
    image: mycompany/users-service:latest
    networks:
      - internal
    environment:
      - PORT=8080

  orders-service:
    image: mycompany/orders-service:latest
    networks:
      - internal
    environment:
      - PORT=8081

networks:
  internal:
    driver: bridge

Thanks to the volume-mounted configuration, the dynamic reloader picks up changes to config.yaml without restarting the container.

Logging, Metrics, and Basic Monitoring

For logging, we use structured output via the standard log/slog package (Go 1.21+):

package middleware

import (
    "log/slog"
    "net/http"
    "time"
)

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
        next.ServeHTTP(rw, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", rw.statusCode,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    })
}

For metrics, we add a simple /metrics endpoint that exposes counters in a Prometheus-compatible format:

package metrics

import (
    "fmt"
    "net/http"
    "sync/atomic"
)

var (
    TotalRequests  uint64
    TotalErrors    uint64
    OpenBreakers   uint64
)

func Handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "# HELP gateway_requests_total Total requests\n")
    fmt.Fprintf(w, "gateway_requests_total %d\n", atomic.LoadUint64(&TotalRequests))
    fmt.Fprintf(w, "# HELP gateway_errors_total Total errors\n")
    fmt.Fprintf(w, "gateway_errors_total %d\n", atomic.LoadUint64(&TotalErrors))
    fmt.Fprintf(w, "# HELP gateway_open_breakers Open circuit breakers\n")
    fmt.Fprintf(w, "gateway_open_breakers %d\n", atomic.LoadUint64(&OpenBreakers))
}

The /metrics endpoint integrates easily with Prometheus + Grafana for visualization, providing the minimum viable monitoring for a production environment.

Limitations of This Approach and When to Use Ready-Made Solutions

A custom-built API Gateway is a deliberate trade-off. Here are the honest limitations:

  • No built-in authentication: JWT, OAuth2, and API keys must be implemented manually or via middleware.
  • No rate limiting out of the box: requires a separate module, often with Redis as the counter store.
  • No UI or declarative DSL: YAML configuration is simpler than the Kong Admin API, but less convenient.
  • Testing and maintenance: every feature requires writing tests. Bugs in the circuit breaker can lead to cascading failures.
  • Scaling complexity: horizontal scaling of the gateway itself requires external storage for circuit breaker state.

When to use ready-made solutions:

  • The team is small and lacks resources to maintain a custom gateway.
  • You need a rich plugin ecosystem: request transformation, canary releases, mTLS.
  • The infrastructure already runs on Kubernetes — Traefik or ingress-nginx will get the job done faster.
  • The project is in its early stages and gateway requirements have not yet been finalized.

A custom API Gateway in Go is a valuable learning and production tool for teams that know exactly what they want and are ready to take full ownership of every line of code in a critical infrastructure component.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →