A production-grade Go web server β intentionally minimal in application logic, but maximally realistic in DevOps infrastructure. The real complexity lives in Docker, Kubernetes, Helm, GitHub Actions CI/CD, and ArgoCD GitOps.
- Project Overview
- Architecture Overview
- Go Language Concepts Used
- Application Design
- Docker & Containerization
- Kubernetes Architecture
- Helm Chart
- CI/CD Pipeline
- GitOps with ArgoCD
- Full System Design
- Project Structure
- Quick Start
- API Reference
- Testing
- Security Hardening
This project is a Go HTTP web server that serves four static HTML pages (home, courses, about, contact) on port 8080. It exposes Kubernetes-native health check endpoints and supports zero-downtime graceful shutdown.
What makes it interesting is NOT the application β it's the surrounding infrastructure:
| Layer | Technology | Purpose |
|---|---|---|
| Application | Go 1.22.5 | Lightweight HTTP server, no frameworks |
| Containerization | Docker (Distroless) | ~20 MB production image |
| Orchestration | Kubernetes | Self-healing, auto-scaling |
| Packaging | Helm | Templated, versioned deployments |
| Continuous Integration | GitHub Actions | Test β Build β Push β Update |
| Continuous Delivery | ArgoCD | GitOps-driven cluster sync |
graph TB
subgraph DEV["π¨βπ» Developer Workflow"]
DEV1[Write Code] --> DEV2[git push main]
end
subgraph CI["βοΈ GitHub Actions β CI"]
CI1[Job 1: Test and Lint<br/>go vet + go test -race] --> CI2[Job 2: Build and Push<br/>Docker Image to DockerHub]
CI2 --> CI3[Job 3: Update Manifest<br/>Bump image tag in values.yaml]
end
subgraph GITOPS["π¦ GitOps Source of Truth"]
REPO[(Git Repository<br/>helm/go-web-app/values.yaml<br/>image.tag: sha-abc1234)]
end
subgraph CD["π ArgoCD β CD"]
ARGO1[ArgoCD polls repo<br/>every 3 min] --> ARGO2{Drift<br/>detected?}
ARGO2 -- Yes --> ARGO3[Render Helm Chart]
ARGO3 --> ARGO4[Apply to Cluster]
ARGO2 -- No --> ARGO1
end
subgraph K8S["βΈοΈ Kubernetes Cluster β webapps namespace"]
ING[NGINX Ingress<br/>go-web-app.local] --> SVC[ClusterIP Service<br/>port 80]
SVC --> POD1[Pod 1<br/>:8080]
SVC --> POD2[Pod 2<br/>:8080]
end
USER[π Internet User] -->|DNS| ING
DEV2 --> CI1
CI3 --> REPO
REPO --> ARGO1
ARGO4 --> K8S
style DEV fill:#1e3a5f,color:#fff
style CI fill:#2d4a2d,color:#fff
style GITOPS fill:#4a2d1e,color:#fff
style CD fill:#3d1e4a,color:#fff
style K8S fill:#1e3a4a,color:#fff
mux := http.NewServeMux() // Local multiplexer, avoids global state pollution
mux.HandleFunc("/home", homePage)
mux.HandleFunc("/healthz", healthzHandler)Why a local mux? Using
http.DefaultServeMux(the global one) is dangerous in larger projects β any imported package can silently register routes on it. A localServeMuxgives you full control.
sequenceDiagram
participant main
participant goroutine as Server Goroutine
participant OS
main->>goroutine: go srv.ListenAndServe()
main->>OS: signal.Notify(quit, SIGTERM, SIGINT)
Note over main: blocks on select{}
OS-->>main: SIGTERM from Kubernetes
main->>goroutine: srv.Shutdown(ctx)
goroutine-->>main: in-flight requests drained
main->>main: os.Exit(0)
serverErrors := make(chan error, 1)
go func() {
serverErrors <- srv.ListenAndServe() // Non-blocking start
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-serverErrors: // Port already in use, etc.
log.Fatalf("server error: %v", err)
case sig := <-quit: // OS signal received
srv.Shutdown(ctx) // Graceful drain
}Key Go concepts at play:
- Goroutines β lightweight threads (2 KB stack vs. 1 MB for OS threads)
- Channels β typed message-passing between goroutines (
chan error,chan os.Signal) selectstatement β waits on multiple channel operations simultaneously (like a switch for channels)- Buffered channel (
make(chan error, 1)) β prevents goroutine leak if no one reads the error immediately
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx) // Gracefully drain requests within 30 seconds
context.WithTimeoutcreates a context that automatically cancels after 30 seconds. This is Go's idiomatic way to propagate deadlines through the call stack.defer cancel()prevents a context leak.
// All handlers follow the same signature: http.HandlerFunc
func homePage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/home.html")
}Go's http.HandlerFunc is a function type that implements the http.Handler interface. This is Go's take on the strategy pattern β any function with the right signature is a handler.
srv := &http.Server{
Addr: "0.0.0.0:8080",
ReadTimeout: 10 * time.Second, // Max time to read request
WriteTimeout: 10 * time.Second, // Max time to write response
IdleTimeout: 60 * time.Second, // Keep-alive connection idle timeout
}Without timeouts, a slow client can hold connections open forever, eventually exhausting the server's file descriptors (the "Slowloris" attack).
cases := []struct {
name string
handler http.HandlerFunc
wantStatus int
}{
{"home page", homePage, 200},
{"healthz probe", healthzHandler, 200},
{"readyz probe", readyzHandler, 200},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
req, _ := http.NewRequest("GET", tc.path, nil)
tc.handler.ServeHTTP(rr, req)
// assertions...
})
}Table-driven tests are idiomatic Go. Adding a new case is a single struct literal β no new test function needed.
httptest.NewRecorder()lets handlers run without a real TCP connection.
flowchart TD
A[Kubernetes sends SIGTERM] --> B[OS signal received on quit channel]
B --> C["context.WithTimeout(30s) created"]
C --> D[srv.Shutdown ctx called]
D --> E{Active requests?}
E -- Yes --> F[Wait for requests to complete]
F --> E
E -- No --> G[Server stops accepting new connections]
G --> H[Process exits cleanly]
C -->|30s elapsed| I[Force shutdown]
I --> H
| Method | Path | Handler | Description |
|---|---|---|---|
GET |
/home |
homePage |
Serves static/home.html |
GET |
/courses |
coursePage |
Serves static/courses.html |
GET |
/about |
aboutPage |
Serves static/about.html |
GET |
/contact |
contactPage |
Serves static/contact.html |
GET |
/healthz |
healthzHandler |
Liveness probe β responds ok |
GET |
/readyz |
readyzHandler |
Readiness probe β responds ready |
stateDiagram-v2
[*] --> Starting: Pod created
Starting --> Ready: /readyz returns 200
Ready --> Running: Traffic routed to pod
Running --> Unhealthy: /healthz fails 3x
Unhealthy --> [*]: Kubernetes restarts container
Running --> Draining: SIGTERM received
Draining --> [*]: Graceful shutdown complete
Liveness vs. Readiness β Why Two Probes?
| Probe | Endpoint | Failure Action | Checks |
|---|---|---|---|
| Liveness | /healthz |
Container restarted | Is the process alive? |
| Readiness | /readyz |
Pod removed from load balancer | Is the app ready for traffic? |
graph LR
subgraph Stage1["Stage 1: builder (golang:1.22.5-alpine3.20)"]
S1A[COPY go.mod] --> S1B[go mod download]
S1B --> S1C[COPY source code]
S1C --> S1D["go build -ldflags='-w -s' -o main"]
S1D --> S1E[Binary: /app/main]
end
subgraph Stage2["Stage 2: final (distroless/static-debian12:nonroot)"]
S2A[COPY binary from builder] --> S2B[COPY static/ from builder]
S2B --> S2C[USER nonroot:nonroot]
S2C --> S2D[EXPOSE 8080]
S2D --> S2E["ENTRYPOINT [./main]"]
end
S1E -->|Only binary + static files copied| Stage2
style Stage1 fill:#1a3a5c,color:#fff
style Stage2 fill:#1a3c1a,color:#fff
| Image | Size | Shell | Package Manager | Attack Surface |
|---|---|---|---|---|
golang:1.22.5 |
~800 MB | bash | apt | High |
golang:1.22.5-alpine |
~250 MB | sh | apk | Medium |
gcr.io/distroless/static |
~20 MB | None | None | Minimal |
CGO_ENABLED=0 # Disable C interop β fully static binary (required for distroless)
GOOS=linux # Cross-compile for Linux (works from macOS/Windows dev machines)
GOARCH=amd64 # Target x86-64 CPUs (most cloud VMs)
-ldflags="-w -s" # Strip debug symbols β reduces binary size by ~30%
-o main # Output binary name# CORRECT: go.mod copied first (changes rarely β cached)
COPY go.mod ./
RUN go mod download # cached unless go.mod changes
COPY . . # source code (changes often β cache miss here only)
RUN go build ...Copying
go.modbefore the rest of the source code meansgo mod downloadis only re-executed when dependencies change, saving 30β60 seconds per build.
graph TD
NS[Namespace: webapps] --> DEP[Deployment: go-web-app]
NS --> SVC[Service: go-web-app<br/>ClusterIP :80]
NS --> ING[Ingress: go-web-app<br/>go-web-app.local]
DEP --> RS[ReplicaSet]
RS --> POD1[Pod 1<br/>container: :8080]
RS --> POD2[Pod 2<br/>container: :8080]
ING -->|routes to| SVC
SVC -->|load balances| POD1
SVC -->|load balances| POD2
POD1 -->|liveness| LV1[GET /healthz]
POD1 -->|readiness| RD1[GET /readyz]
POD2 -->|liveness| LV2[GET /healthz]
POD2 -->|readiness| RD2[GET /readyz]
style NS fill:#1e3a5f,color:#fff
style DEP fill:#2d4a2d,color:#fff
style SVC fill:#4a3d1e,color:#fff
style ING fill:#3d1e4a,color:#fff
sequenceDiagram
participant K8s as Kubernetes Scheduler
participant OLD1 as Old Pod 1
participant OLD2 as Old Pod 2
participant NEW1 as New Pod 1
participant NEW2 as New Pod 2
Note over K8s: New image tag detected
K8s->>NEW1: Create new pod (3 pods running)
K8s->>NEW1: Poll /readyz...
NEW1-->>K8s: 200 Ready
K8s->>OLD1: Send SIGTERM
OLD1-->>K8s: Drain in-flight requests (30s)
K8s->>OLD1: Pod terminated
Note over K8s: Back to 2 pods
K8s->>NEW2: Create new pod
K8s->>NEW2: Poll /readyz...
NEW2-->>K8s: 200 Ready
K8s->>OLD2: Send SIGTERM
OLD2-->>K8s: Drain and terminate
Note over K8s: Rolling update complete
Rolling update config (maxSurge: 1, maxUnavailable: 0):
maxSurge: 1β Allow 1 extra pod during updates (temporarily 3 pods instead of 2)maxUnavailable: 0β Never remove an old pod until the new one is healthy- Result: True zero-downtime deployments
# Pod level
securityContext:
runAsNonRoot: true # No root processes
runAsUser: 65532 # Distroless nonroot user
# Container level
securityContext:
allowPrivilegeEscalation: false # Cannot sudo/setuid
readOnlyRootFilesystem: true # Cannot write to disk
capabilities:
drop: [ALL] # No Linux capabilitiesflowchart LR
USER["π User"] -->|"DNS: go-web-app.local"| LB["Cloud Load Balancer"]
LB --> NGINX["NGINX Ingress Controller"]
NGINX -->|"path: /* β rewrite: /"| SVC["Service: go-web-app<br/>ClusterIP :80"]
SVC -->|Round Robin| P1["Pod 1 :8080"]
SVC -->|Round Robin| P2["Pod 2 :8080"]
P1 --> HTML["static/*.html"]
P2 --> HTML
style USER fill:#2d4a6e,color:#fff
style LB fill:#6e4a2d,color:#fff
style NGINX fill:#2d6e4a,color:#fff
Rate limiting: NGINX Ingress limits each client IP to 20 requests/second (nginx.ingress.kubernetes.io/limit-rps: "20"), providing basic DDoS protection.
| Resource | Request | Limit | Reasoning |
|---|---|---|---|
| CPU | 50m (5% core) | 100m (10% core) | Static file server; very CPU-light |
| Memory | 64 Mi | 128 Mi | Go binary (~10MB) + OS overhead |
Requests guide the Kubernetes scheduler (which node to place the pod on). Limits prevent one bad pod from starving the entire node.
Helm is a package manager for Kubernetes. Instead of maintaining separate YAML files for dev/staging/prod, Helm lets you write templates once and override values per environment.
helm/go-web-app/
βββ Chart.yaml # Chart metadata (name, version, description)
βββ values.yaml # Default configuration values
βββ template/ # Kubernetes manifest templates
βββ deployment.yaml
βββ service.yaml
βββ ingress.yaml
graph LR
V["values.yaml<br/>image.tag: sha-abc1234"] -->|helm template| T[Template Engine]
OV["Override values<br/>--set image.tag=sha-xyz"] -->|higher priority| T
T --> D["deployment.yaml<br/>image: sars2006/go-web-app:sha-abc1234"]
T --> S[service.yaml]
T --> I[ingress.yaml]
D --> K[kubectl apply]
S --> K
I --> K
style V fill:#1a3a5c,color:#fff
style T fill:#3a1a5c,color:#fff
style K fill:#1a5c3a,color:#fff
replicaCount: 2 # Number of pods
image:
repository: sars2006/go-web-app
tag: sha-2b6580fec7 # Auto-updated by CI pipeline
pullPolicy: IfNotPresent
service:
type: ClusterIP # Internal only; Ingress handles external traffic
port: 80
ingress:
enabled: true
host: go-web-app.local # Replace with real domain in production
tls:
enabled: false # Enable with cert-manager for HTTPSflowchart TD
PUSH[git push to main] --> J1
subgraph J1["Job 1: Test and Lint"]
T1[actions/checkout@v4] --> T2["actions/setup-go@v5<br/>Go 1.22.5 + module cache"]
T2 --> T3["go vet ./...<br/>static analysis"]
T3 --> T4["go test -race -v -count=1 ./...<br/>unit tests + race detector"]
end
J1 -->|tests pass| J2
subgraph J2["Job 2: Build and Push (push only, not PRs)"]
B1[docker/setup-buildx-action@v3] --> B2["docker/login-action@v3<br/>DockerHub credentials"]
B2 --> B3["docker/metadata-action@v5<br/>tags: latest + sha-abc1234"]
B3 --> B4["docker/build-push-action@v5<br/>push: true + registry cache"]
end
J2 -->|image pushed| J3
subgraph J3["Job 3: Update Manifest (GitOps)"]
M1[Checkout repo with GITHUB_TOKEN] --> M2["sed -i 's/tag:.*/tag: sha-abc1234/'<br/>helm/go-web-app/values.yaml"]
M2 --> M3["git commit 'chore: update image tag skip ci'"]
M3 --> M4[git push]
end
J3 --> ARGOCD[ArgoCD detects new commit<br/>and syncs to cluster]
style J1 fill:#1a3a5c,color:#fff
style J2 fill:#1a5c3a,color:#fff
style J3 fill:#5c3a1a,color:#fff
style ARGOCD fill:#3a1a5c,color:#fff
Tests (seconds) β Docker Build (minutes) β Manifest Update (seconds)
Running tests first catches broken code before wasting 2β3 minutes building a Docker image that will never be deployed.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueIf two pushes happen in quick succession, the older pipeline run is cancelled. This prevents a race condition where an older build could overwrite a newer image tag in
values.yaml.
| Tag | Example | Purpose |
|---|---|---|
latest |
sars2006/go-web-app:latest |
Human-friendly, mutable alias |
sha-<short> |
sars2006/go-web-app:sha-a1b2c3d |
Immutable, traceable to exact commit |
Always use SHA tags in Kubernetes manifests. The
latesttag is mutable β it changes silently and makes rollbacks and debugging extremely difficult.
| Secret | Location | Used By |
|---|---|---|
DOCKER_USERNAME |
GitHub Repo β Settings β Secrets | Job 2: DockerHub login |
DOCKER_PASSWORD |
GitHub Repo β Settings β Secrets | Job 2: DockerHub login |
GITHUB_TOKEN |
Auto-provided by GitHub Actions | Job 3: commit back to repo |
GitOps means Git is the single source of truth for your cluster's desired state. Instead of running kubectl apply or helm upgrade manually, you commit a change and ArgoCD applies it automatically.
graph LR
subgraph Traditional["Traditional Imperative Approach"]
A1[Developer] -->|"kubectl apply / helm upgrade"| A2[Kubernetes Cluster]
end
subgraph GitOps["GitOps Declarative Approach"]
B1[Developer] -->|git commit| B2[Git Repository]
B2 -->|ArgoCD watches| B3[ArgoCD]
B3 -->|kubectl apply| B4[Kubernetes Cluster]
B4 -.->|drift detected β self-heal| B3
end
style Traditional fill:#5c1a1a,color:#fff
style GitOps fill:#1a5c1a,color:#fff
sequenceDiagram
participant GIT as Git Repository
participant ARGO as ArgoCD Controller
participant K8S as Kubernetes API
loop Every 3 minutes
ARGO->>GIT: Poll for new commits
GIT-->>ARGO: Latest commit SHA
ARGO->>K8S: Get live state
K8S-->>ARGO: Current resources
ARGO->>ARGO: Compare desired vs live state
alt Drift detected
ARGO->>ARGO: Render Helm chart
ARGO->>K8S: Apply manifests
K8S-->>ARGO: Applied successfully
else In sync
ARGO->>ARGO: Nothing to do
end
end
spec:
source:
repoURL: 'https://github.com/DevSars24/go-web-app.git'
path: helm/go-web-app # Helm chart location in repo
targetRevision: HEAD # Always track latest main branch
destination:
server: 'https://kubernetes.default.svc'
namespace: webapps
syncPolicy:
automated:
prune: true # Delete resources removed from Git
selfHeal: true # Revert manual kubectl changes automatically| Feature | Config | Effect |
|---|---|---|
| Auto-sync | automated: {} |
Deploys on every new Git commit |
| Prune | prune: true |
Removes orphaned K8s resources |
| Self-heal | selfHeal: true |
Reverts manual cluster changes |
| CreateNamespace | syncOptions |
Auto-creates webapps namespace |
| ServerSideApply | syncOptions |
Better merge handling, no annotation limits |
| Cascade delete | finalizers |
Cleans up all resources when ArgoCD app is deleted |
# With GitOps, rollback = revert a commit
git revert <bad-commit-sha>
git push origin main
# ArgoCD detects the revert and rolls back the cluster automaticallygraph TB
subgraph INTERNET["Internet"]
USER["π End User<br/>Browser"]
DEV["π¨βπ» Developer<br/>git push"]
end
subgraph GITHUB["GitHub"]
REPO["π Repository<br/>Source Code + Helm Chart"]
ACTIONS["βοΈ GitHub Actions<br/>CI Pipeline"]
end
subgraph REGISTRY["Container Registry"]
DHUB["π³ DockerHub<br/>sars2006/go-web-app"]
end
subgraph CLUSTER["βΈοΈ Kubernetes Cluster"]
subgraph ARGOCD_NS["argocd namespace"]
ARGO["π ArgoCD<br/>GitOps Operator"]
end
subgraph INGRESS_NS["ingress-nginx namespace"]
NGINX["π NGINX Ingress<br/>Controller"]
end
subgraph WEBAPPS_NS["webapps namespace"]
ING_RES["Ingress Resource<br/>go-web-app.local"]
SVC["ClusterIP Service<br/>:80"]
POD1["Pod 1<br/>Go App :8080"]
POD2["Pod 2<br/>Go App :8080"]
end
end
DEV -->|1. git push| REPO
REPO -->|2. triggers| ACTIONS
ACTIONS -->|3. docker push| DHUB
ACTIONS -->|4. update values.yaml| REPO
REPO -->|5. poll for changes| ARGO
DHUB -->|6. pull image| POD1
DHUB -->|6. pull image| POD2
ARGO -->|7. apply manifests| WEBAPPS_NS
USER -->|HTTP request| NGINX
NGINX --> ING_RES
ING_RES --> SVC
SVC --> POD1
SVC --> POD2
style INTERNET fill:#1a1a2e,color:#fff
style GITHUB fill:#0d1117,color:#fff
style REGISTRY fill:#1c2a4a,color:#fff
style CLUSTER fill:#0f2027,color:#fff
style ARGOCD_NS fill:#2d1b69,color:#fff
style INGRESS_NS fill:#1b2d69,color:#fff
style WEBAPPS_NS fill:#1b6940,color:#fff
sequenceDiagram
participant B as Browser
participant DNS as DNS Resolver
participant LB as Cloud Load Balancer
participant NGINX as NGINX Ingress
participant SVC as K8s Service
participant POD as Go App Pod
B->>DNS: resolve go-web-app.local
DNS-->>B: Load Balancer IP
B->>LB: GET /courses HTTP/1.1
LB->>NGINX: forward request
NGINX->>NGINX: match Host header + path rewrite
NGINX->>SVC: forward to ClusterIP:80
SVC->>POD: load balance to :8080
POD->>POD: http.ServeFile courses.html
POD-->>SVC: HTTP 200 + HTML body
SVC-->>NGINX: response
NGINX-->>LB: response
LB-->>B: HTTP 200
go-web-app/
βββ main.go # HTTP server, routes, graceful shutdown
βββ main_test.go # Table-driven tests with httptest
βββ go.mod # Go module definition (zero external deps)
β
βββ Dockerfile # Multi-stage build β ~20 MB distroless image
β
βββ static/ # Static HTML pages served by the Go app
β βββ home.html
β βββ courses.html
β βββ about.html
β βββ contact.html
β
βββ K8s/manifests/ # Raw Kubernetes manifests
β βββ namespace.yaml # Creates the webapps namespace
β βββ deployment.yaml # 2 replicas, rolling update, security contexts
β βββ service.yaml # ClusterIP service on port 80
β βββ ingress.yaml # NGINX Ingress with rate limiting
β
βββ helm/go-web-app/ # Helm chart (recommended for production)
β βββ Chart.yaml # Chart metadata
β βββ values.yaml # Default values (image tag auto-updated by CI)
β βββ template/ # Parameterized K8s manifests
β
βββ argocd-app.yaml # ArgoCD Application manifest (GitOps CD)
β
βββ .github/workflows/
βββ cicd.yml # 3-job GitHub Actions pipeline
# Clone the repository
git clone https://github.com/DevSars24/go-web-app.git
cd go-web-app
# Run directly with Go (no Docker needed)
go run main.go
# Visit http://localhost:8080/home# Build the image
docker build -t go-web-app:local .
# Run the container
docker run -p 8080:8080 go-web-app:local
# Visit http://localhost:8080/home# Install NGINX Ingress Controller (one-time setup)
helm upgrade --install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx --create-namespace
# Deploy the application
helm upgrade --install go-web-app ./helm/go-web-app \
--namespace webapps --create-namespace
# Add to /etc/hosts for local testing
echo "$(minikube ip) go-web-app.local" | sudo tee -a /etc/hosts
# Visit http://go-web-app.local# Install ArgoCD
kubectl create namespace argocd
kubectl apply -n argocd -f \
https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# Register the application
kubectl apply -f argocd-app.yaml
# Access ArgoCD UI
kubectl port-forward svc/argocd-server -n argocd 8080:443
# Open https://localhost:8080| Endpoint | Status Code | Response Body | Content-Type |
|---|---|---|---|
GET /home |
200 OK |
home.html |
text/html; charset=utf-8 |
GET /courses |
200 OK |
courses.html |
text/html; charset=utf-8 |
GET /about |
200 OK |
about.html |
text/html; charset=utf-8 |
GET /contact |
200 OK |
contact.html |
text/html; charset=utf-8 |
GET /healthz |
200 OK |
ok\n |
text/plain; charset=utf-8 |
GET /readyz |
200 OK |
ready\n |
text/plain; charset=utf-8 |
# Liveness probe
curl http://localhost:8080/healthz
# Output: ok
# Readiness probe
curl http://localhost:8080/readyz
# Output: ready# Run all tests
go test ./...
# Run with verbose output
go test -v ./...
# Run with race detector (detects concurrent access bugs)
go test -race ./...
# Run a specific test
go test -run TestHealthzBody ./...
# Check test coverage
go test -cover ./...Tests use net/http/httptest β no real network connections, no ports opened:
graph LR
TC[Test Case] -->|http.NewRequest| REQ["*http.Request"]
REQ --> REC[httptest.NewRecorder]
REC -->|handler.ServeHTTP| H[Handler Function]
H --> RESP[Captured Response in Memory]
RESP --> ASSERT["Assertions:<br/>status code, Content-Type, body"]
style TC fill:#1a3a5c,color:#fff
style ASSERT fill:#1a5c1a,color:#fff
graph TB
subgraph L1["Layer 1: Container Image"]
IMG["Distroless base image<br/>No shell Β· No package manager Β· No debug tools"]
end
subgraph L2["Layer 2: Container Runtime"]
RT1[Non-root user uid=65532]
RT2[Read-only root filesystem]
RT3[No Linux capabilities]
RT4[No privilege escalation]
end
subgraph L3["Layer 3: Network"]
NET1["NGINX rate limiting (20 req/sec per IP)"]
NET2[ClusterIP Service β no external exposure]
NET3[TLS-ready Ingress with cert-manager support]
end
subgraph L4["Layer 4: Application"]
APP1[Server timeouts β 10s read/write]
APP2[Graceful shutdown β 30s drain window]
APP3[Zero external dependencies β no library CVEs]
end
L1 --> L2 --> L3 --> L4
style L1 fill:#5c1a1a,color:#fff
style L2 fill:#5c3a1a,color:#fff
style L3 fill:#1a3a5c,color:#fff
style L4 fill:#1a5c1a,color:#fff
- Distroless base image β minimal attack surface (~20 MB)
- Non-root container user β
uid=65532(nonroot) - Read-only filesystem β
readOnlyRootFilesystem: true - No Linux capabilities β
drop: [ALL] - No privilege escalation β
allowPrivilegeEscalation: false - Server timeouts β prevents Slowloris-style attacks
- Rate limiting β 20 req/sec per IP via NGINX
- Immutable image tags β SHA-based tags, not
latest - Secrets in GitHub Secrets β never in source code
- Least-privilege CI β only
contents: writepermission - No external Go dependencies β stdlib only, zero CVE exposure
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Commit your changes:
git commit -m 'feat: add my feature' - Push to the branch:
git push origin feature/my-feature - Open a Pull Request β CI runs tests automatically on every PR
This project is licensed under the MIT License β see LICENSE for details.
Built with Go Β· Docker Β· Kubernetes Β· Helm Β· GitHub Actions Β· ArgoCD
The best way to learn DevOps is to build something real.