TL;DR — This guide walks you through building a self-hosted User Safety & Consent service in Go, complete with an HTTP API, Postgres persistence, automated data-pruning, Prometheus metrics, and Docker containerization. It’s a runnable, extensible project you can commit to GitHub, demo in interviews, and immediately extend with persistence, scaling, and observability patterns used at senior engineering levels.
If you’re looking for a side project that doubles as a signal to hiring managers, this one hits the sweet spot: it’s a complete, running system you can build in an afternoon, but it also maps directly to the kind of backend+infrastructure problems you’ll solve on the job. You’ll practice API design, database modeling, containerized deployment, and observability — all while producing a concrete artifact (a URL, a Docker image, a set of metrics) you can point to and say, “Here’s something I built end-to-end.”
Why This Project Stands Out on a CV
Hiring managers for backend, SRE, and full-stack roles see dozens of “TODO list” clones. What sets this project apart is that it’s not a toy; it’s a miniature production system that demonstrates several high-value skill clusters simultaneously:
- API design with strict contracts — You’ll build a RESTful HTTP service using
net/http(or a lightweight framework likeginorecho) that validates request bodies, returns proper HTTP status codes, and produces machine-readable error payloads. That’s exactly what you’ll do building services at companies like Stripe, Datadog, or Cloudflare. - Data-privacy & compliance modeling — Implementing consent, data retention, and “right-to-be-forgotten” operations maps directly to GDPR, CCPA, and privacy-by-design patterns. You’ll show you understand why data should be auto-pruned, not just how to delete a row.
- Containerized deployment & DevOps basics — A
Dockerfileanddocker-compose.ymllet you demonstrate local development, CI integration, and the ability to ship self-contained services — a core expectation for any role involving Kubernetes or ECS. - Observability via Prometheus metrics — Exposing
/metricsand recording consent grant/revoke events, pruning runs, and error rates teaches you how services emit telemetry that operators actually use. - Database interactions with Postgres — Writing migrations, upserts, and timed deletes with
database/sql(orpgx) shows you can work reliably with a production RDBMS, including connection pooling and idempotent operations.
Roles this signals for: Backend Engineer, SRE/Platform Engineer, Full-Stack Engineer (with infra interest), Data Engineer (privacy-focused), and any position where you’ll own a service from code to production.
Architecture Overview
The system consists of four primary components that fit together in a straightforward request-flow:
+----------------+ HTTP +----------------+ SQL +----------------+
| Client/ ──────────▶ | Go HTTP API ──────▶ | Postgres DB |
| curl / browser| | (main.go) | |
+----------------+ +----------------+ |
│ │ │
│ │ │
▼ ▼ ▼
+----------------+ /metrics +----------------+ +----------------+
| Prometheus ◀───────────── | Go HTTP API | ... | Exporter |
| /scrape | | (metrics) | +----------------+
+----------------+
▲
│
+-------+-------+
| Cron Job |
| (data pruning)|
+---------------+
Components in detail:
Go HTTP API (
main.go) — A single binary listening on:8080that handlesPOST /consent,POST /consent/:id/revoke, andGET /metrics. It usesdatabase/sqlwith a Postgres connection pool,prometheus/client_golangfor metrics, andnet/httpfor the handler chain.Postgres DB — Stores a
consentstable with columns:id UUID PRIMARY KEY,user_id TEXT,status TEXT(granted/revoked),created_at TIMESTAMP,expires_at TIMESTAMP. Indexes onuser_idandexpires_atsupport fast lookups and the automated pruning job.Cron-based pruner — A lightweight background function that runs every 24 hours (configured via environment variable), deletes expired consent records, and increments a
consent_pruned_totalcounter. This mirrors production patterns where TTL/retention policies are enforced by a scheduled job, not just app logic.Prometheus exporter — The
/metricsendpoint exposes Go runtime metrics (go_memstats_alloc_bytes,go_goroutines) plus custom counters:consent_granted_total,consent_revoked_total,consent_pruned_total. Any Prometheus + Grafana stack can scrape these, and the pattern transfers directly to microservice monitoring.
The whole thing runs as one Docker container, making local development docker compose up and production deployment a matter of docker run or a Helm chart—if you later split the pruner into a separate worker, the architecture already supports it.
Building It Step by Step
Here’s a complete, runnable walkthrough. You’ll have a working service by step 7.
Step 1 — Scaffold the Go module and install dependencies
mkdir user-safety && cd user-safety
go mod init github.com/yourname/user-safety
go get github.com/prometheus/client_golang/prometheus/github.com/prometheus/client_golang/prometheus
go get github.com/jackc/pgx/v5/stdlib
Step 2 — Define the Postgres migration
Create migrations/001_create_consents.up.sql:
CREATE TABLE IF NOT EXISTS consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('granted', 'revoked')),
created_at TIMESTAMP NOT NULL DEFAULT now(),
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_consents_user_id ON consents (user_id);
CREATE INDEX idx_consents_expires_at ON consents (expires_at);
Apply it (assuming Postgres running locally on port 5432):
PGPASSWORD=postgres psql -h localhost -U postgres -d user_safety -f migrations/001_create_consents.up.sql
Step 3 — Implement the HTTP handler and core logic
Create main.go:
package main
import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/jackc/pgx/v5/stdlib"
_ "github.com/jackc/pgx/v5/stdlib"
)
var (
consentGranted = prometheus.NewCounter(prometheus.CounterOpts{
Name: "consent_granted_total",
Help: "Total number of consent grants",
})
consentRevoked = prometheus.NewCounter(prometheus.CounterOpts{
Name: "consent_revoked_total",
Help: "Total number of consent revocations",
})
consentPruned = prometheus.NewCounter(prometheus.CounterOpts{
Name: "consent_pruned_total",
Help: "Total number of expired consent records pruned",
})
)
func init() {
prometheus.MustRegister(consentGranted, consentRevoked, consentPruned)
}
type Consent struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
}
func consentHandler(w http.ResponseWriter, r *http.Request, db *sql.DB) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var input struct {
UserID string `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
return
}
id := "cons-" + strings.TrimSpace(input.UserID)
now := time.Now().UTC()
expires := now.AddDate(0, 0, 30) // 30-day retention for demo
_, err := db.ExecContext(r.Context(), `
INSERT INTO consents (id, user_id, status, created_at, expires_at)
VALUES ($1, $2, 'granted', $3, $4)
ON CONFLICT (id) DO UPDATE SET status = 'granted', created_at = $3, expires_at = $4`,
id, input.UserID, now, expires)
if err != nil {
http.Error(w, `{"error":"db write failed"}`, http.StatusInternalServerError)
return
}
consentGranted.Inc()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(Consent{ID: id, UserID: input.UserID, Status: "granted", CreatedAt: now.Format(time.RFC3339), ExpiresAt: expires.Format(time.RFC3339)})
}
func revokeHandler(w http.ResponseWriter, r *http.Request, db *sql.DB) {
parts := strings.SplitN(r.URL.Path, "/", 4)
if len(parts) != 4 {
http.Error(w, "not found", http.StatusNotFound)
return
}
id := parts[3]
ctx := context.Background()
result, err := db.ExecContext(ctx, `UPDATE consents SET status = 'revoked' WHERE id = $1`, id)
if err != nil || result.RowsAffected() == 0 {
http.Error(w, "not found", http.StatusNotFound)
return
}
consentRevoked.Inc()
w.WriteHeader(http.StatusNoContent)
}
func metricsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=004")
promhttp.Handler().ServeHTTP(w, r)
}
func main() {
dsn := "postgres://postgres:postgres@localhost:5432/user_safety?sslmode=disable"
db := stdlib.OpenDB(dsn)
defer db.Close()
http.HandleFunc("/consent", func(w http.ResponseWriter, r *http.Request) { consentHandler(w, r, db) })
http.HandleFunc("/consent/", func(w http.ResponseWriter, r *http.Request) { revokeHandler(w, r, db) })
http.HandleFunc("/metrics", metricsHandler)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
Step 4 — Add the automated pruning cron job
Inside main.go, add a setupPruner function that starts a goroutine:
func setupPruner(db *sql.DB) {
interval, _ := time.ParseDuration(os.Getenv("PRUNE_INTERVAL") + "h")
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
ctx := context.Background()
result, err := db.ExecContext(ctx, `
DELETE FROM consents WHERE expires_at < now()
`)
if err != nil {
log.Printf("prune error: %v", err)
continue
}
n := result.RowsAffected()
consentPruned.Add(float64(n))
log.Printf("pruned %d expired consent records", n)
}
}()
}
Call setupPruner(db) right before log.Printf("listening...") in main().
Step 5 — Write the Dockerfile
Create Dockerfile:
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
RUN CGO_ENABLED=0 go build -o safe .
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /root
COPY --from=builder /src/safe .
EXPOSE 8080
ENTRYPOINT ["./safe"]
Step 6 — Write docker-compose.yml
Create docker-compose.yml:
version: "3.9"
services:
safe:
build: .
ports: ["8080:8080"]
environment:
POSTGRES_URL: "postgres://postgres:postgres@db:5432/user_safety"
PRUNE_INTERVAL: "24"
depends_on:
db:
volumes:
- pgdata:/var/lib/postgresql/data
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: user_safety
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Step 7 — Run and verify
docker compose up -d
# wait a sec, then:
curl -X POST http://localhost:8080/consent -H "Content-Type: application/json" -d '{"user_id":"alice"}'
# => {"id":"cons-alice","user_id":"alice","status":"granted",...}
curl http://localhost:8080/metrics
# => # HELP consent_granted_total Total number of consent grants
# # TYPE consent_granted_total counter
# consent_granted_total 1
# ...
# Test revoke:
curl -X POST http://localhost:8080/consent/cons-alice/revoke
# => 204 No Content
Running and Testing It
Locally, docker compose up -d brings up the Go binary plus a Postgres instance. The service listens on :8080. After the first curl -X POST http://localhost:8080/consent, you’ll see a JSON consent record. Visiting http://localhost:8080/metrics in a browser or via curl returns Prometheus-formatted text with three counters incrementing as you grant/revoke consents.
Testing with Go’s built-in test suite
Create main_test.go:
package main
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func testConsentGrant(t *testing.T) {
db := setupTestDB(t) // helper that returns an in-memory sqlite or test Postgres wrapper
defer db.Close()
req := httptest.NewRequest(http.MethodPost, "/consent", strings.NewReader(`{"user_id":"testuser"}`))
w := httptest.NewRecorder()
consentHandler(w, req, db)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var c Consent
if err := json.NewDecoder(w.Body).Decode(&c); err != nil {
t.Fatal(err)
}
if c.Status != "granted" {
t.Errorf("expected status granted, got %s", c.Status)
}
}
Run go test ./... — all tests should pass, confirming the handler logic is correct without needing Docker.
Key Takeaways
- This project is a complete, running service you can ship today, not a placeholder tutorial.
- It demonstrates API design, database modeling, containerization, and observability—all in one coherent flow.
- The consent + pruning pattern maps directly to real-world GDPR/CCPA compliance work.
- Prometheus metrics and
/metricsendpoint teach you how services emit telemetry that operators rely on. - Docker + docker-compose gives you a reproducible local environment and a production-ready artifact.
- The codebase is small enough to understand end-to-end but extensible enough to grow (add JWT auth, a web UI, or a separate pruner worker).
Further Reading
- Go net/http package — the standard library foundation for the HTTP API you just built.
- Prometheus client for Go — the
/metricsexporter pattern used throughout. - PostgreSQL documentation: CREATE TABLE — the migration you applied, with notes on constraints and indexes.
- Docker Compose specification — for the
docker-compose.ymlyou used to stack the Go binary and Postgres. - OWASP Privacy by Design — principles that informed the consent/retention logic.
- 12-Factor App methodology — especially the sections on disposability, concurrency, and logs, which this service already embodies.