TL;DR — This guide walks you through building a runnable user safety incident tracking system from scratch. You’ll get a Go backend, Postgres schema, API endpoints, Docker local run, CI-friendly testing, and a roadmap to production-grade features like persistence, scaling, and observability — all concrete enough to ship and talk about in interviews.

Building a portfolio project that actually demonstrates production-system thinking is one of the fastest ways to stand out to hiring managers. Generic “To-Do list” clones are forgettable; a system that involves API design, database modeling, containerization, testing, and a clear upgrade path to production features signals that you can ship real code, not just pseudocode. In this post we’ll build safe, a user safety incident tracking system that an engineer can run locally, extend, and point to in interviews as a fully considered, production‑flavored piece of work.

Why this matters

Recruiters and engineering managers scan dozens of GitHub profiles. A project with a clear architecture, runnable code, and a documented roadmap gives them concrete hooks for interview questions: “Why did you choose Postgres?” “How would you scale this?” “Show me the test you’re most proud of.” safe answers all of those without hand-waving.

Why This Project Stands Out on a CV

safe demonstrates a cluster of skills that hiring managers for backend, platform, and reliability roles explicitly look for. First, API design with intent: you’ll build a RESTful API that handles resource ownership, status transitions, and audit logging — not just CRUD, but meaningful domain logic. Second, database schema design: Postgres tables with proper foreign keys, indexes for query performance, and a migration strategy via Goose or similar. Third, containerization and local development: Docker Compose brings the whole stack (API + DB) up with one command, a pattern used in almost every mid‑size engineering org. Fourth, test coverage: unit tests for handlers, integration tests spinning up a test DB, and a simple CI pipeline configuration. Fifth, observability scaffolding: you’ll add structured logging and Prometheus metrics endpoints that can be dropped into an existing monitoring stack.

The roles this signals for include Backend Engineer, Site Reliability Engineer, Platform Engineer, and any position where you’re expected to take a feature from prototype to production. Because the project is deliberately scoped — a single, focused domain rather than a vague “full‑stack app” — you can speak to every line of code and every design decision, which is far more impressive than a repository with 200 loosely connected files.

Architecture Overview

safe consists of four primary components that fit together in a straightforward, deployable pattern:

  • HTTP API (Go net/http + gorilla/mux or standard library) — handles request validation, business logic, and responses.
  • Postgres database — stores incidents, users, and audit timestamps. Schema is intentionally normalized to avoid denormalization pitfalls early on.
  • Docker Compose — orchestrates the API container and a Postgres container, with volume mounting for persistence across restarts.
  • Optional observability layer — a /metrics endpoint exposing Prometheus counters and a structured‑log output via logrus.

A text diagram of the request flow:

client
  │
  ▼  POST /api/v1/incidents  HTTP/1.1
  ┌─────────────────────────────────────► API container
  │  │  validate JSON, insert into Postgres
  │  │  return 201 + location header
  │  └─────────────────────────────────────
  │                                   │
  │                                   ▼
  │                            SELECT * FROM incidents
  │                                   │
  ▼  GET /api/v1/incidents      HTTP/1.1
  └─────────────────────────────────────► API container
                                      │
                                      ▼
                               Postgres returns rows

If you later add a message broker (e.g., NATS or RabbitMQ) for async incident processing, the diagram simply gains a “queue” box between API and DB, but the core flow remains the same.

Building It Step by Step

We’ll construct safe in seven numbered steps. Each step includes a runnable code snippet tagged with the language.

Step 1 — Project scaffolding

mkdir safe && cd safe
go mod init safe
go get github.com/go-chi/chi/v2

Step 2 — Postgres schema

We’ll use a single incidents table with an audit column.

CREATE TABLE incidents (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title       TEXT NOT NULL,
    description TEXT,
    status      TEXT NOT NULL DEFAULT 'open',
    reported_by UUID REFERENCES users(id),
    created_at  TIMESTAMPTZ DEFAULT now(),
    updated_at  TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_incidents_status ON incidents(status);
CREATE INDEX idx_incidents_reported_by ON incidents(reported_by);

Step 3 — Database model and connection

// models.go
package safe

import (
    "database/sql"
    "fmt"
    "log"
)

type Incident struct {
    ID        string `json:"id"`
    Title     string `json:"title"`
    Status    string `json:"status"`
    CreatedAt string `json:"created_at"`
}

func InitDB(dsn string) *sql.DB {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        log.Fatalf("failed to open DB: %v", err)
    }
    if err := db.Ping(); err != nil {
        log.Fatalf("failed to ping DB: %v", err)
    }
    fmt.Println("✅ DB connection alive")
    return db
}

Step 4 — API handler for creating an incident

// handlers.go
package main

import (
    "database/sql"
    "encoding/json"
    "net/http"
    "github.com/go-chi/chi/v2"
    "github.com/go-chi/chi/v2/middleware"
)

func createIncident(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var input struct {
            Title       string `json:"title"`
            Description string `json:"description,omitempty"`
        }
        if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
            http.Error(w, "invalid JSON", http.StatusBadRequest)
            return
        }
        var id string
        err := db.QueryRow(
            "INSERT INTO incidents (title, description, status) VALUES ($1, $2, 'open') RETURNING id",
            input.Title, input.Description,
        ).Scan(&id)
        if err != nil {
            http.Error(w, "db error", http.StatusInternalServerError)
            return
        }
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(map[string]string{"id": id})
    }
}

func main() {
    dsn := "host=localhost user=safe password=safe dbname=safe sslmode=disable"
    db := InitDB(dsn)

    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Post("/api/v1/incidents", createIncident(db))

    http.ListenAndServe(":3000", r)
}

Step 5 — Listing incidents

func listIncidents(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        rows, err := db.Query("SELECT id, title, status, created_at FROM incidents")
        if err != nil {
            http.Error(w, "db error", http.StatusInternalServerError)
            return
        }
        defer rows.Close()
        var incidents []struct {
            ID        string `json:"id"`
            Title     string `json:"title"`
            Status    string `json:"status"`
            CreatedAt string `json:"created_at"`
        }
        for rows.Next() {
            var i struct {
                ID        string `json:"id"`
                Title     string `json:"title"`
                Status    string `json:"status"`
                CreatedAt string `json:"created_at"`
            }
            if err := rows.Scan(&i.ID, &i.Title, &i.Status, &i.CreatedAt); err != nil {
                http.Error(w, "scan error", http.StatusInternalServerError)
                return
            }
            incidents = append(incidents, i)
        }
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(incidents)
    }
}

func main() {
    // ... db init as before ...
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Post("/api/v1/incidents", createIncident(db))
    r.Get("/api/v1/incidents", listIncidents(db))
    http.ListenAndServe(":3000", r)
}

Step 6 — Docker Compose for local development

version: "3.8"
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DSN: "host=db user=safe password=safe dbname=safe sslmode=disable"
    depends_on:
      - db
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: safe
      POSTGRES_PASSWORD: safe
      POSTGRES_DB: safe
    volumes:
      - pg_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  pg_data:

Run docker compose up --build and the API is at http://localhost:3000. The schema auto‑creates on first run if you add a migration step, but for this guide we’ll assume the table exists.

Step 7 — Basic test suite

// handlers_test.go
package main

import (
    "database/sql"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestCreateIncident(t *testing.T) {
    // spin up in‑memory sqlite for simplicity, or use a testcontainer
    // here we just verify the handler returns 201 on valid input
    input := `{"title":"test incident"}`
    req := httptest.NewRequest("POST", "/api/v1/incidents", strings.NewReader(input))
    w := httptest.NewRecorder()
    createIncident(db)(w, req)
 if w.Code != http.StatusCreated {
     t.Errorf("expected 201, got %d", w.Code)
 }
}

Running and Testing It

After docker compose up --build, verify the API responds:

curl -X POST http://localhost:3000/api/v1/incidents -d '{"title":"first report"}' -H "Content-Type: application/json"
# → 201 {"id":"<uuid>"}

curl http://localhost:3000/api/v1/incidents
# → JSON array of all incidents

To run the test suite locally:

go test ./...
# → ok

If you prefer a pure Docker‑based test, spin a temporary Postgres container via docker compose -f docker-compose.test.yml up where that file overrides the DSN to point at a transient container. The key pattern is: your code should never hard‑code connection strings; always read them from environment variables, which makes local, CI, and production runs identical.

Extending It: Your Roadmap to Senior-Level

Here are six concrete upgrades that transform safe from a toy into a production‑flavored system, each with a one‑line reason it matters:

  1. Add persistence migrations with Goose — manage schema evolution version‑controlled so you can safely alter the incidents table as the product grows.
  2. Introduce a NATS streaming server for async incident ingestion — decouples the API from slow DB writes, enabling fire‑and‑forget reporting under high load.
  3. Instrument with OpenTelemetry and expose Prometheus metrics — http_requests_total, incidents_created_total let SREs set alerts and understand real‑world usage patterns.
  4. Implement role‑based access control (RBAC) with JWT middleware — ensures only authorized teams can update or close incidents, a must‑have for any customer‑facing system.
  5. Add fault‑tolerance via a circuit‑breaker (go‑circuit) — prevents cascading failures if Postgres latency spikes, gracefully degrading response quality.
  6. Benchmark with k6 and add load‑testing CI step — proves the API sustains, say, 500 req/s on a single replica, giving you data‑driven confidence before scaling.

Each upgrade is a real pattern you’ll encounter at scale, and each can be demonstrated in an interview as a concrete improvement you’ve already validated.

Further Reading

Key Takeaways

  • safe turns a vague “portfolio idea” into a runnable, documented system you can point to in interviews.
  • The project demonstrates API design, database modeling, containerization, testing, and observability — all in a single focused domain.
  • Each upgrade path (migrations, async queues, RBAC, circuit‑breakers, benchmarking) maps directly to production‑engineer expectations.
  • Real tools (Go, Postgres, Docker Compose, OpenTelemetry) make the codebase interview‑ready and immediately extensible.
  • A clear roadmap from toy to production‑flavored system shows hiring managers you think beyond the first commit.