- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia) - Jason.decode! → Jason.decode with error handling for untrusted input - inspect() leak: replace with generic error messages, log details server-side - SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes - SSRF validation added to HTTP monitoring executor and integration credentials - GraphQL complexity limits: always applied, not just in prod - GraphQL introspection: also check GET query params, not just body - Stripe webhook: explicit nil/empty checks for signature and body - Cookie security: secure flag for session (prod), http_only+secure for remember_me - Honeybadger API key: read from env var with fallback - String.to_integer → Integer.parse with fallback for URL params - String.to_atom → whitelist map for HTTP methods - Gaiia webhook: remove secret_len and expected signature from log - Admin API: add rate limiting pipeline - to_atom_keys: per-key fallback instead of all-or-nothing rescue
74 KiB
SRE/DevOps Best Practices Reference
Practical patterns, real configs, and battle-tested commands. Not academic theory.
Table of Contents
- Reliability Engineering
- Observability
- Infrastructure as Code
- Kubernetes
- CI/CD
- Networking & Security
- Database Operations
- On-Call & Alerting
1. Reliability Engineering
SLIs, SLOs, and SLAs
SLI (Service Level Indicator) — A quantitative measure of some aspect of service. SLO (Service Level Objective) — A target value or range for an SLI. SLA (Service Level Agreement) — A contract with consequences if the SLO is missed.
Rule of thumb: SLA threshold < SLO threshold (always have internal margin).
Common SLIs
| Service Type | SLI | How to Measure |
|---|---|---|
| HTTP API | Availability | successful_requests / total_requests |
| HTTP API | Latency | requests < 300ms / total_requests |
| Data pipeline | Freshness | time_since_last_successful_run |
| Storage | Durability | intact_objects / total_objects |
| Batch job | Throughput | records_processed / expected_records |
Defining SLOs — Concrete Example
# slo.yaml — Structured SLO definition
service: towerops-api
slos:
- name: availability
sli: "ratio of HTTP requests with status < 500"
target: 99.9% # 43.8 min/month downtime budget
window: 30d rolling
- name: latency-p99
sli: "99th percentile response time"
target: 500ms
window: 30d rolling
- name: latency-p50
sli: "50th percentile response time"
target: 100ms
window: 30d rolling
Error Budgets
Error budget = 1 - SLO. For a 99.9% availability SLO over 30 days:
Budget = 0.1% × 30 days × 24 hours × 60 minutes = 43.2 minutes of downtime
Error budget policy:
| Budget Remaining | Action |
|---|---|
| > 50% | Ship features freely |
| 20–50% | Require extra review, feature flag new code |
| 5–20% | Freeze non-critical changes, focus on reliability |
| < 5% | Full freeze — only reliability work ships |
PromQL to track error budget consumption:
# Error budget remaining (0 = exhausted, 1 = full)
1 - (
(
sum(rate(http_requests_total{status=~"5.."}[30d]))
/
sum(rate(http_requests_total[30d]))
) / (1 - 0.999)
)
Toil Reduction
Toil = manual, repetitive, automatable, tactical, devoid of long-term value, scales linearly with service growth.
Identifying Toil
Track it explicitly. Every on-call rotation, log toil:
## Toil Log — Week of 2026-03-09
| Task | Time Spent | Frequency | Automatable? | Ticket |
|---|---|---|---|---|
| Restart stuck workers | 15 min | 3x/week | Yes — add health check + auto-restart | TOIL-042 |
| Manually rotate certs | 30 min | Monthly | Yes — cert-manager | TOIL-043 |
| Resize PVCs | 20 min | 2x/month | Yes — auto-expand policy | TOIL-044 |
Target: Keep toil below 50% of on-call time. If it's above that, you're not doing engineering — you're doing ops.
Automation Priority Matrix
High frequency + Easy to automate → DO IT NOW
High frequency + Hard to automate → Invest time, high ROI
Low frequency + Easy to automate → Quick win, do it
Low frequency + Hard to automate → Probably not worth it
Incident Response
Severity Levels
| Severity | Criteria | Response Time | Examples |
|---|---|---|---|
| SEV-1 | Complete outage, data loss risk, security breach | 15 min | API down, DB corruption, credentials leaked |
| SEV-2 | Major feature degraded, significant user impact | 30 min | Auth broken, payments failing, 50%+ error rate |
| SEV-3 | Minor feature degraded, workaround exists | 4 hours | Slow queries, non-critical service down |
| SEV-4 | Cosmetic, minimal impact | Next business day | Dashboard glitch, log noise |
Incident Response Process
1. DETECT → Alert fires or user reports
2. TRIAGE → Assign severity, page on-call if needed
3. MITIGATE → Stop the bleeding (rollback, scale, redirect)
4. DIAGNOSE → Find root cause (but mitigation comes first!)
5. RESOLVE → Fix the underlying issue
6. FOLLOWUP → Postmortem within 48 hours
Key principle: Mitigate first, diagnose second. Rolling back a deploy takes 2 minutes. Finding the bug in the deploy might take 2 hours. Your users don't care about root cause during an outage.
Incident Commander Checklist
## Incident Commander Checklist
- [ ] Declare incident and severity in #incidents channel
- [ ] Assign roles: IC (you), Communications Lead, Ops Lead
- [ ] Start incident doc (copy from template)
- [ ] Set up war room (video call link in channel)
- [ ] Post status update every 15 minutes (SEV-1) or 30 minutes (SEV-2)
- [ ] Communicate to stakeholders via status page
- [ ] When resolved: announce resolution, schedule postmortem
Runbook Template
See Section 8 for the full runbook template.
Capacity Planning
The Four Signals to Monitor
# 1. Utilization — How full is the resource?
avg(node_cpu_seconds_total{mode!="idle"}) by (instance)
sum(container_memory_working_set_bytes) / sum(machine_memory_bytes)
# 2. Saturation — How queued/overloaded is it?
rate(node_disk_io_time_weighted_seconds_total[5m])
avg_over_time(node_load15[1h]) / count(node_cpu_seconds_total{mode="idle"}) by (instance)
# 3. Rate of growth
predict_linear(node_filesystem_avail_bytes[7d], 30*24*3600) # 30-day projection
# 4. Headroom — How much buffer remains?
(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes
Capacity Planning Process
# Export resource usage trends for capacity review
# Run monthly, review in capacity planning meeting
# CPU: average and peak over past 30 days
curl -s "http://prometheus:9090/api/v1/query?query=\
quantile_over_time(0.95, instance:node_cpu_utilisation:rate5m[30d])" | jq '.data.result'
# Memory: project when we hit 80%
curl -s "http://prometheus:9090/api/v1/query?query=\
predict_linear(node_memory_MemAvailable_bytes[30d], 90*24*3600)" | jq '.data.result'
# Disk: days until full
curl -s "http://prometheus:9090/api/v1/query?query=\
node_filesystem_avail_bytes / ignoring(mountpoint) \
rate(node_filesystem_avail_bytes[7d]) / -86400" | jq '.data.result'
Rule of thumb: Plan for 2x current peak. If you're above 60% sustained utilization, start provisioning.
2. Observability
The three pillars: Metrics, Logs, Traces. Each answers different questions:
- Metrics → "What is broken?" (symptoms)
- Logs → "Why is it broken?" (details)
- Traces → "Where is it broken?" (request path)
Prometheus + Grafana
Prometheus Configuration
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
cluster: production
environment: prod
rule_files:
- "/etc/prometheus/rules/*.yml"
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
scrape_configs:
- job_name: "kubernetes-pods"
kubernetes_sd_configs:
- role: pod
relabel_configs:
# Only scrape pods with annotation prometheus.io/scrape: "true"
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
# Use custom port if annotated
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: (.+)
replacement: ${1}:${2}
# Use custom path if annotated
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- job_name: "node-exporter"
static_configs:
- targets: ["node-exporter:9100"]
- job_name: "postgres"
static_configs:
- targets: ["postgres-exporter:9187"]
Essential PromQL Queries
# --- HTTP Request Rate ---
# Requests per second by status code
sum(rate(http_requests_total[5m])) by (status_code)
# Error rate (5xx / total)
sum(rate(http_requests_total{status_code=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
# --- Latency ---
# P50/P95/P99 from histogram
histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))
histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))
# Apdex score (satisfied < 0.3s, tolerating < 1.2s)
(
sum(rate(http_request_duration_seconds_bucket{le="0.3"}[5m]))
+ sum(rate(http_request_duration_seconds_bucket{le="1.2"}[5m]))
) / 2 / sum(rate(http_request_duration_seconds_count[5m]))
# --- Saturation ---
# CPU saturation (load average > CPU count)
node_load1 / count without(cpu, mode) (node_cpu_seconds_total{mode="idle"})
# Memory pressure
1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)
# Disk I/O saturation
rate(node_disk_io_time_weighted_seconds_total[5m])
# --- Kubernetes-specific ---
# Pod restart rate (crash loops)
rate(kube_pod_container_status_restarts_total[15m]) > 0
# Container OOMKills
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}
# HPA at max replicas (can't scale further)
kube_horizontalpodautoscaler_status_current_replicas
== kube_horizontalpodautoscaler_spec_max_replicas
# Pending pods (scheduling issues)
kube_pod_status_phase{phase="Pending"} > 0
# PVC usage approaching limits
kubelet_volume_stats_used_bytes / kubelet_volume_stats_capacity_bytes > 0.85
# --- PostgreSQL ---
# Active connections vs max
pg_stat_activity_count / pg_settings_max_connections
# Slow queries (from pg_stat_statements exporter)
topk(10, pg_stat_statements_mean_time_seconds)
# Replication lag
pg_replication_lag_seconds > 30
# Cache hit ratio (should be > 99%)
pg_stat_database_blks_hit / (pg_stat_database_blks_hit + pg_stat_database_blks_read)
Alert Rules
# alerts.yml
groups:
- name: slo-alerts
rules:
# Multi-window multi-burn-rate alert (Google SRE book pattern)
# Fast burn: 14.4x burn rate over 1h (uses 2% of budget in 1h)
- alert: HighErrorBudgetBurn_Fast
expr: |
(
sum(rate(http_requests_total{status_code=~"5.."}[1h]))
/ sum(rate(http_requests_total[1h]))
) > (14.4 * 0.001)
and
(
sum(rate(http_requests_total{status_code=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m]))
) > (14.4 * 0.001)
for: 2m
labels:
severity: critical
annotations:
summary: "High error budget burn rate"
description: "Burning error budget at 14.4x. {{ $value | humanizePercentage }} error rate over 1h."
runbook_url: "https://wiki.internal/runbooks/high-error-rate"
# Slow burn: 1x burn rate over 3d (steady degradation)
- alert: HighErrorBudgetBurn_Slow
expr: |
(
sum(rate(http_requests_total{status_code=~"5.."}[3d]))
/ sum(rate(http_requests_total[3d]))
) > (1 * 0.001)
and
(
sum(rate(http_requests_total{status_code=~"5.."}[6h]))
/ sum(rate(http_requests_total[6h]))
) > (1 * 0.001)
for: 1h
labels:
severity: warning
annotations:
summary: "Slow error budget burn detected"
description: "Steady error rate consuming budget. Review recent changes."
- name: infrastructure
rules:
- alert: NodeHighCPU
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
for: 15m
labels:
severity: warning
annotations:
summary: "High CPU on {{ $labels.instance }}"
description: "CPU usage above 85% for 15 minutes. Current: {{ $value }}%"
- alert: NodeHighMemory
expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 > 90
for: 10m
labels:
severity: warning
annotations:
summary: "High memory on {{ $labels.instance }}"
description: "Memory usage above 90%. Current: {{ $value }}%"
- alert: DiskWillFillIn24h
expr: predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|fuse.lxcfs"}[6h], 24*3600) < 0
for: 30m
labels:
severity: warning
annotations:
summary: "Disk will fill within 24h on {{ $labels.instance }}"
description: "Filesystem {{ $labels.mountpoint }} projected to run out of space."
- alert: PodCrashLooping
expr: rate(kube_pod_container_status_restarts_total[15m]) * 60 * 15 > 3
for: 5m
labels:
severity: warning
annotations:
summary: "Pod {{ $labels.namespace }}/{{ $labels.pod }} crash looping"
description: "Container {{ $labels.container }} restarted {{ $value }} times in 15m."
- name: postgres
rules:
- alert: PostgresHighConnections
expr: pg_stat_activity_count / pg_settings_max_connections > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "PostgreSQL connections at {{ $value | humanizePercentage }}"
- alert: PostgresReplicationLag
expr: pg_replication_lag_seconds > 60
for: 5m
labels:
severity: critical
annotations:
summary: "Replication lag {{ $value }}s on {{ $labels.instance }}"
- alert: PostgresLowCacheHitRatio
expr: |
pg_stat_database_blks_hit{datname!~"template.*"}
/ (pg_stat_database_blks_hit{datname!~"template.*"} + pg_stat_database_blks_read{datname!~"template.*"})
< 0.99
for: 15m
labels:
severity: warning
annotations:
summary: "Low cache hit ratio on {{ $labels.datname }}: {{ $value }}"
Alertmanager Configuration
# alertmanager.yml
global:
resolve_timeout: 5m
slack_api_url: "https://hooks.slack.com/services/T00/B00/XXXX"
route:
receiver: "default"
group_by: ["alertname", "namespace"]
group_wait: 30s # Wait to batch related alerts
group_interval: 5m # Wait before sending updated group
repeat_interval: 4h # Re-notify interval
routes:
- match:
severity: critical
receiver: "pagerduty-critical"
group_wait: 10s
repeat_interval: 1h
- match:
severity: warning
receiver: "slack-warnings"
repeat_interval: 8h
receivers:
- name: "default"
slack_configs:
- channel: "#alerts"
title: '{{ .GroupLabels.alertname }}'
text: >-
{{ range .Alerts }}
*{{ .Labels.severity | toUpper }}*: {{ .Annotations.summary }}
{{ .Annotations.description }}
{{ end }}
- name: "pagerduty-critical"
pagerduty_configs:
- service_key: "<PD_SERVICE_KEY>"
description: '{{ .GroupLabels.alertname }}: {{ (index .Alerts 0).Annotations.summary }}'
severity: critical
- name: "slack-warnings"
slack_configs:
- channel: "#alerts-warnings"
title: '⚠️ {{ .GroupLabels.alertname }}'
text: '{{ (index .Alerts 0).Annotations.description }}'
inhibit_rules:
# Don't alert on warning if critical is already firing
- source_match:
severity: critical
target_match:
severity: warning
equal: ["alertname", "namespace"]
Structured Logging
Stop logging unstructured strings. Use JSON with consistent fields:
{
"timestamp": "2026-03-13T21:27:00.000Z",
"level": "error",
"message": "Failed to process payment",
"service": "billing-api",
"trace_id": "abc123def456",
"span_id": "span789",
"request_id": "req-uuid-here",
"user_id": "usr_12345",
"error": {
"type": "PaymentGatewayTimeout",
"message": "Connection timed out after 30s",
"stack": "..."
},
"context": {
"payment_amount": 49.99,
"currency": "USD",
"gateway": "stripe",
"retry_count": 2
}
}
Required Log Fields
Every log line should have at minimum:
timestamp — ISO 8601 (with timezone)
level — debug, info, warn, error, fatal
message — Human-readable description
service — Which service produced this
trace_id — For correlation across services
Elixir Structured Logging Example
# config/config.exs
config :logger, :default_formatter,
format: {LoggerJSON.Formatters.Basic, :format},
metadata: [:request_id, :trace_id, :span_id, :user_id]
# In application code
Logger.error("Payment failed",
user_id: user.id,
payment_id: payment.id,
error: inspect(reason),
gateway: "stripe",
amount: amount
)
OpenTelemetry Tracing
Elixir Setup
# mix.exs
defp deps do
[
{:opentelemetry, "~> 1.4"},
{:opentelemetry_exporter, "~> 1.7"},
{:opentelemetry_phoenix, "~> 1.2"},
{:opentelemetry_ecto, "~> 1.2"},
]
end
# config/runtime.exs
config :opentelemetry,
span_processor: :batch,
exporter: {:opentelemetry_exporter, %{
protocol: :grpc,
endpoints: [System.get_env("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")]
}}
config :opentelemetry, :resource,
service: [name: "towerops-api", version: Mix.Project.config()[:version]]
# application.ex — instrument Phoenix and Ecto
def start(_type, _args) do
OpentelemetryPhoenix.setup(adapter: :cowboy2)
OpentelemetryEcto.setup([:towerops, :repo])
# ...
end
Custom Spans
require OpenTelemetry.Tracer, as: Tracer
def process_device_command(device_id, command) do
Tracer.with_span "device.process_command", %{
attributes: [
{"device.id", device_id},
{"command.type", command.type}
]
} do
with {:ok, device} <- lookup_device(device_id),
{:ok, result} <- send_command(device, command) do
Tracer.set_attribute("command.result", "success")
{:ok, result}
else
{:error, reason} ->
Tracer.set_status(:error, inspect(reason))
Tracer.set_attribute("command.result", "failure")
{:error, reason}
end
end
end
Log Aggregation with Loki
Promtail Configuration (ships logs to Loki)
# promtail-config.yml
server:
http_listen_port: 9080
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
pipeline_stages:
# Parse JSON logs
- json:
expressions:
level: level
message: message
trace_id: trace_id
# Set log level as label
- labels:
level:
# Drop debug logs in production
- match:
selector: '{level="debug"}'
action: drop
# Extract timestamp from log line
- timestamp:
source: timestamp
format: RFC3339Nano
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
target_label: app
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
LogQL Query Examples
# All errors from billing service in last hour
{app="billing-api"} |= "error" | json | level="error"
# Errors with specific trace ID (incident investigation)
{namespace="production"} | json | trace_id="abc123def456"
# Top 10 error messages
sum by (message) (count_over_time({app="towerops-api"} | json | level="error" [1h]))
# Latency from logs (if you log request duration)
{app="towerops-api"} | json | duration > 1s
# Rate of errors per service
sum(rate({namespace="production"} | json | level="error" [5m])) by (app)
3. Infrastructure as Code
Terraform Patterns
Project Structure
terraform/
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── versions.tf
│ ├── k8s-cluster/
│ ├── database/
│ └── cdn/
├── environments/
│ ├── prod/
│ │ ├── main.tf # Composes modules
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ ├── staging/
│ └── dev/
├── global/ # Shared resources (DNS, IAM)
│ ├── main.tf
│ └── backend.tf
└── scripts/
└── plan-all.sh
Module Pattern — VPC Example
# modules/vpc/variables.tf
variable "name" {
type = string
description = "VPC name"
}
variable "cidr" {
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
type = list(string)
description = "AZs for subnet distribution"
}
variable "enable_nat_gateway" {
type = bool
default = true
}
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = var.name
ManagedBy = "terraform"
Environment = var.name
}
}
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr, 8, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.name}-public-${var.availability_zones[count.index]}"
Tier = "public"
}
}
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr, 8, count.index + length(var.availability_zones))
availability_zone = var.availability_zones[count.index]
tags = {
Name = "${var.name}-private-${var.availability_zones[count.index]}"
Tier = "private"
}
}
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
Environment Composition
# environments/prod/main.tf
module "vpc" {
source = "../../modules/vpc"
name = "prod"
cidr = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
enable_nat_gateway = true
}
module "k8s" {
source = "../../modules/k8s-cluster"
cluster_name = "prod"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
node_count = 5
instance_type = "m6i.xlarge"
k8s_version = "1.29"
}
module "database" {
source = "../../modules/database"
name = "prod"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
instance_class = "db.r6g.xlarge"
engine_version = "16.2"
multi_az = true
backup_retention = 30
}
Remote State Configuration
# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "towerops-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
# Reading state from another workspace
data "terraform_remote_state" "global" {
backend = "s3"
config = {
bucket = "towerops-terraform-state"
key = "global/terraform.tfstate"
region = "us-east-1"
}
}
# Use: data.terraform_remote_state.global.outputs.route53_zone_id
Terraform Best Practices — Quick Reference
# Always plan before apply
terraform plan -out=plan.tfplan
terraform apply plan.tfplan
# Import existing resources
terraform import aws_instance.web i-1234567890abcdef0
# Target specific resources (use sparingly)
terraform plan -target=module.database
# Move state (rename without destroy/recreate)
terraform state mv aws_instance.old aws_instance.new
# Detect drift
terraform plan -detailed-exitcode # Exit 2 = drift detected
# Workspace management (alternative to directory-per-env)
terraform workspace new staging
terraform workspace select staging
terraform workspace list
State locking is non-negotiable. Always use DynamoDB (AWS) or equivalent. Two engineers running terraform apply simultaneously = destroyed infrastructure.
NixOS for Reproducible Servers
NixOS Server Configuration
# /etc/nixos/configuration.nix
{ config, pkgs, ... }:
{
imports = [
./hardware-configuration.nix
./services/prometheus.nix
./services/postgres.nix
./networking.nix
];
# System basics
system.stateVersion = "24.11";
time.timeZone = "America/Chicago";
# Automatic updates with rollback capability
system.autoUpgrade = {
enable = true;
allowReboot = false; # Manual reboot approval
channel = "https://nixos.org/channels/nixos-24.11";
};
# Minimal packages
environment.systemPackages = with pkgs; [
vim
git
htop
curl
jq
ripgrep
];
# Firewall
networking.firewall = {
enable = true;
allowedTCPPorts = [ 22 80 443 ];
allowedUDPPorts = [ ];
};
# SSH hardening
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "no";
KbdInteractiveAuthentication = false;
};
extraConfig = ''
AllowUsers deploy
MaxAuthTries 3
'';
};
# Automatic garbage collection
nix.gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 30d";
};
# Users
users.users.deploy = {
isNormalUser = true;
extraGroups = [ "wheel" "docker" ];
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAA... deploy@towerops"
];
};
# Security
security.sudo.wheelNeedsPassword = true;
}
NixOS Service Module Example
# services/prometheus.nix
{ config, pkgs, ... }:
{
services.prometheus = {
enable = true;
port = 9090;
retentionTime = "90d";
globalConfig = {
scrape_interval = "15s";
evaluation_interval = "15s";
};
scrapeConfigs = [
{
job_name = "node";
static_configs = [{
targets = [ "localhost:9100" ];
}];
}
{
job_name = "postgres";
static_configs = [{
targets = [ "localhost:9187" ];
}];
}
];
rules = [
(builtins.toJSON {
groups = [{
name = "system";
rules = [{
alert = "HighCPU";
expr = "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100) > 85";
for = "15m";
labels.severity = "warning";
}];
}];
})
];
};
services.prometheus.exporters.node = {
enable = true;
port = 9100;
enabledCollectors = [ "systemd" "processes" ];
};
services.grafana = {
enable = true;
settings = {
server = {
http_port = 3000;
domain = "grafana.towerops.net";
};
security = {
admin_password = "$__file{/run/secrets/grafana-admin-password}";
};
};
};
}
NixOS Deployment
# Build without switching (test first)
nixos-rebuild build --flake .#my-server
# Switch to new configuration
nixos-rebuild switch --flake .#my-server
# Rollback to previous generation
nixos-rebuild switch --rollback
# List generations (your deployment history)
nix-env --list-generations --profile /nix/var/nix/profiles/system
# Remote deployment
nixos-rebuild switch --flake .#prod-server \
--target-host deploy@prod.towerops.net \
--use-remote-sudo
Key NixOS advantage: Every deployment is atomic and rollbackable. If generation 42 breaks, nixos-rebuild switch --rollback takes you back to generation 41 in seconds. No Ansible "undo" scripts needed.
4. Kubernetes
Deployment with Best Practices
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: towerops-api
namespace: production
labels:
app: towerops-api
version: v1.4.2
spec:
replicas: 3
revisionHistoryLimit: 5 # Keep 5 old ReplicaSets for rollback
selector:
matchLabels:
app: towerops-api
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 1 extra pod during rollout
maxUnavailable: 0 # Never drop below desired count
template:
metadata:
labels:
app: towerops-api
version: v1.4.2
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "4000"
prometheus.io/path: "/metrics"
spec:
serviceAccountName: towerops-api
terminationGracePeriodSeconds: 60
# Don't schedule all replicas on the same node
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: towerops-api
containers:
- name: towerops-api
image: ghcr.io/towerops/api:v1.4.2 # Always pin exact tags
ports:
- containerPort: 4000
protocol: TCP
# Resource limits — ALWAYS set these
resources:
requests:
cpu: 250m # Guaranteed minimum
memory: 256Mi
limits:
cpu: "1" # Hard ceiling
memory: 512Mi # OOMKilled if exceeded
# Liveness: is the process alive? Restart if not.
livenessProbe:
httpGet:
path: /healthz
port: 4000
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
# Readiness: can it serve traffic? Remove from LB if not.
readinessProbe:
httpGet:
path: /readyz
port: 4000
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
# Startup: for slow-starting apps. Liveness/readiness wait until this passes.
startupProbe:
httpGet:
path: /healthz
port: 4000
failureThreshold: 30 # 30 × 2s = 60s to start
periodSeconds: 2
# Graceful shutdown
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"] # Let LB drain connections
env:
- name: MIX_ENV
value: "prod"
- name: PHX_HOST
value: "api.towerops.net"
- name: PORT
value: "4000"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: towerops-secrets
key: database-url
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: towerops-secrets
key: secret-key-base
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
sizeLimit: 100Mi
# Security context
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
Health Check Implementation
# In your Phoenix router — separate liveness and readiness
scope "/", TowerOpsWeb do
# Liveness: "is the BEAM VM running?"
get "/healthz", HealthController, :liveness
# Readiness: "can we serve traffic?"
# Checks DB connection, required services, etc.
get "/readyz", HealthController, :readiness
end
# lib/towerops_web/controllers/health_controller.ex
defmodule TowerOpsWeb.HealthController do
use TowerOpsWeb, :controller
def liveness(conn, _params) do
conn |> put_status(200) |> json(%{status: "ok"})
end
def readiness(conn, _params) do
checks = %{
database: check_database(),
redis: check_redis()
}
if Enum.all?(Map.values(checks), &(&1 == :ok)) do
conn |> put_status(200) |> json(%{status: "ready", checks: checks})
else
conn |> put_status(503) |> json(%{status: "not_ready", checks: checks})
end
end
defp check_database do
case Ecto.Adapters.SQL.query(TowerOps.Repo, "SELECT 1", []) do
{:ok, _} -> :ok
_ -> :error
end
end
defp check_redis do
case Redix.command(:redix, ["PING"]) do
{:ok, "PONG"} -> :ok
_ -> :error
end
end
end
Service & Ingress
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: towerops-api
namespace: production
spec:
type: ClusterIP
selector:
app: towerops-api
ports:
- port: 80
targetPort: 4000
protocol: TCP
---
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: towerops-api
namespace: production
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/rate-limit: "100"
nginx.ingress.kubernetes.io/rate-limit-window: "1m"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- api.towerops.net
secretName: towerops-api-tls
rules:
- host: api.towerops.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: towerops-api
port:
number: 80
Horizontal Pod Autoscaler
# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: towerops-api
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: towerops-api
minReplicas: 3
maxReplicas: 20
behavior:
scaleUp:
stabilizationWindowSeconds: 60 # Prevent flapping
policies:
- type: Pods
value: 4 # Add max 4 pods at a time
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5 min before scaling down
policies:
- type: Percent
value: 25 # Remove max 25% at a time
periodSeconds: 60
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
# Custom metric: requests per second per pod
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "100"
Pod Disruption Budget
# pdb.yaml — Ensure minimum availability during voluntary disruptions
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: towerops-api
namespace: production
spec:
# At least 2 pods must always be running
minAvailable: 2
# OR: at most 1 pod can be disrupted at a time
# maxUnavailable: 1
selector:
matchLabels:
app: towerops-api
Helm vs Kustomize
When to Use Each
| Criteria | Helm | Kustomize |
|---|---|---|
| Third-party charts | ✅ Great | ❌ Not designed for this |
| Environment overlays | Works (values files) | ✅ Built for this |
| Templating | Full Go templates | Patches only (no templates) |
| Complexity | Higher | Lower |
| Learning curve | Steeper | Gentler |
| Debugging | helm template + squint |
WYSIWYG patches |
Recommendation: Use Helm for third-party dependencies (nginx-ingress, cert-manager, prometheus). Use Kustomize for your own application manifests.
Kustomize Example
k8s/
├── base/
│ ├── kustomization.yaml
│ ├── deployment.yaml
│ ├── service.yaml
│ └── ingress.yaml
├── overlays/
│ ├── staging/
│ │ ├── kustomization.yaml
│ │ ├── replicas-patch.yaml
│ │ └── env-patch.yaml
│ └── production/
│ ├── kustomization.yaml
│ ├── replicas-patch.yaml
│ └── hpa.yaml
# base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- ingress.yaml
commonLabels:
app: towerops-api
# overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
- ../../base
- hpa.yaml
patches:
- path: replicas-patch.yaml
images:
- name: ghcr.io/towerops/api
newTag: v1.4.2
# overlays/production/replicas-patch.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: towerops-api
spec:
replicas: 3
# Preview what will be applied
kubectl kustomize overlays/production
# Apply
kubectl apply -k overlays/production
# Diff against live cluster
kubectl diff -k overlays/production
Kubernetes Operations — Quick Reference
# --- Deployments ---
kubectl rollout status deployment/towerops-api -n production
kubectl rollout history deployment/towerops-api -n production
kubectl rollout undo deployment/towerops-api -n production # Rollback to previous
kubectl rollout undo deployment/towerops-api -n production --to-revision=3 # Specific revision
# --- Debugging ---
kubectl get pods -n production -o wide # Pod status + node
kubectl describe pod <pod-name> -n production # Events, conditions
kubectl logs <pod-name> -n production --tail=100 -f # Stream logs
kubectl logs <pod-name> -n production -c <container> --previous # Crashed container logs
kubectl top pods -n production --sort-by=memory # Resource usage
kubectl get events -n production --sort-by=.lastTimestamp # Recent events
# --- Emergency ---
kubectl scale deployment/towerops-api -n production --replicas=10 # Scale manually
kubectl cordon <node-name> # Prevent scheduling
kubectl drain <node-name> --ignore-daemonsets --delete-emptydir-data # Evacuate node
5. CI/CD
Pipeline Design Principles
- Fast feedback first — Lint and unit tests run in < 2 minutes
- Fail fast — Don't run slow integration tests if linting fails
- Immutable artifacts — Build once, promote to each environment
- No manual steps — If a human has to click something, automate it
GitHub Actions Pipeline
# .github/workflows/ci.yml
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
lint-and-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: towerops_test
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: "1.19"
otp-version: "28"
- uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }}
- run: mix deps.get
- run: mix format --check-formatted
- run: mix compile --warnings-as-errors
- run: mix credo --strict
- run: mix dialyzer
- run: mix test --cover
env:
DATABASE_URL: postgres://postgres:test@localhost/towerops_test
build-and-push:
needs: lint-and-test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=semver,pattern={{version}}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build-and-push
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Update image tag in kustomization
run: |
cd k8s/overlays/staging
kustomize edit set image \
ghcr.io/towerops/api=ghcr.io/towerops/api:${{ needs.build-and-push.outputs.image-tag }}
- name: Apply to staging
run: kubectl apply -k k8s/overlays/staging
env:
KUBECONFIG: ${{ secrets.STAGING_KUBECONFIG }}
- name: Wait for rollout
run: kubectl rollout status deployment/towerops-api -n staging --timeout=300s
env:
KUBECONFIG: ${{ secrets.STAGING_KUBECONFIG }}
- name: Smoke test
run: |
for i in {1..10}; do
status=$(curl -s -o /dev/null -w '%{http_code}' https://staging-api.towerops.net/healthz)
if [ "$status" = "200" ]; then echo "✅ Healthy"; exit 0; fi
sleep 5
done
echo "❌ Smoke test failed"; exit 1
deploy-production:
needs: [build-and-push, deploy-staging]
runs-on: ubuntu-latest
environment: production # Requires manual approval in GitHub
steps:
- uses: actions/checkout@v4
- name: Update image tag
run: |
cd k8s/overlays/production
kustomize edit set image \
ghcr.io/towerops/api=ghcr.io/towerops/api:${{ needs.build-and-push.outputs.image-tag }}
- name: Apply to production
run: kubectl apply -k k8s/overlays/production
env:
KUBECONFIG: ${{ secrets.PROD_KUBECONFIG }}
- name: Wait for rollout
run: kubectl rollout status deployment/towerops-api -n production --timeout=600s
env:
KUBECONFIG: ${{ secrets.PROD_KUBECONFIG }}
GitOps with Flux
# flux-system/gotk-components.yaml — installed via:
# flux bootstrap github \
# --owner=towerops \
# --repository=fleet \
# --branch=main \
# --path=clusters/production
# clusters/production/towerops-api.yaml
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: towerops-api
namespace: flux-system
spec:
interval: 1m
url: https://github.com/towerops/towerops
ref:
branch: main
secretRef:
name: github-token
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: towerops-api
namespace: flux-system
spec:
interval: 5m
path: ./k8s/overlays/production
prune: true # Delete resources removed from git
sourceRef:
kind: GitRepository
name: towerops-api
healthChecks:
- apiVersion: apps/v1
kind: Deployment
name: towerops-api
namespace: production
timeout: 10m
GitOps with ArgoCD
# argocd/applications/towerops-api.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: towerops-api
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/towerops/towerops
targetRevision: main
path: k8s/overlays/production
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true # Revert manual kubectl changes
syncOptions:
- CreateNamespace=true
retry:
limit: 3
backoff:
duration: 5s
maxDuration: 3m
factor: 2
Secrets Management
SOPS + Age (Simple, Git-friendly)
# Install
# nix-env -iA nixpkgs.sops nixpkgs.age
# Generate age key
age-keygen -o ~/.config/sops/age/keys.txt
# .sops.yaml — tells SOPS which keys to use
cat > .sops.yaml << 'EOF'
creation_rules:
- path_regex: secrets/.*\.yaml$
age: >-
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
EOF
# Create encrypted secret
sops secrets/production.yaml
# Opens $EDITOR — write plaintext YAML, SOPS encrypts on save
# Example encrypted file (values encrypted, keys readable)
cat secrets/production.yaml
# database_url: ENC[AES256_GCM,data:abc123...,iv:...,tag:...,type:str]
# secret_key_base: ENC[AES256_GCM,data:def456...,iv:...,tag:...,type:str]
# Decrypt and use
sops -d secrets/production.yaml
# Use with kubectl
sops -d secrets/production.yaml | kubectl create secret generic towerops-secrets \
--from-file=database-url=/dev/stdin \
--dry-run=client -o yaml | kubectl apply -f -
HashiCorp Vault
# --- Vault Setup ---
# Enable KV secrets engine
vault secrets enable -path=secret kv-v2
# Store a secret
vault kv put secret/towerops/production \
database_url="postgres://user:pass@db:5432/towerops" \
secret_key_base="$(openssl rand -hex 64)"
# Read a secret
vault kv get -field=database_url secret/towerops/production
# --- Kubernetes Auth ---
vault auth enable kubernetes
vault write auth/kubernetes/config \
kubernetes_host="https://kubernetes.default.svc"
# Policy: towerops-api can only read its own secrets
vault policy write towerops-api - <<EOF
path "secret/data/towerops/production" {
capabilities = ["read"]
}
EOF
# Role: bind to K8s service account
vault write auth/kubernetes/role/towerops-api \
bound_service_account_names=towerops-api \
bound_service_account_namespaces=production \
policies=towerops-api \
ttl=1h
# Vault Agent Injector — automatically injects secrets into pods
# (requires vault-agent-injector Helm chart)
apiVersion: apps/v1
kind: Deployment
metadata:
name: towerops-api
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "towerops-api"
vault.hashicorp.com/agent-inject-secret-config: "secret/data/towerops/production"
vault.hashicorp.com/agent-inject-template-config: |
{{- with secret "secret/data/towerops/production" -}}
export DATABASE_URL="{{ .Data.data.database_url }}"
export SECRET_KEY_BASE="{{ .Data.data.secret_key_base }}"
{{- end }}
Database Migrations in CI/CD
#!/bin/bash
# scripts/migrate.sh — Safe database migration wrapper
set -euo pipefail
echo "=== Pre-migration checks ==="
# Check for active connections
ACTIVE=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM pg_stat_activity WHERE state = 'active' AND query NOT LIKE '%pg_stat_activity%';")
echo "Active connections: $ACTIVE"
# Check for long-running transactions (could cause lock conflicts)
LONG_TX=$(psql "$DATABASE_URL" -t -c "SELECT count(*) FROM pg_stat_activity WHERE state = 'active' AND now() - xact_start > interval '5 minutes';")
if [ "$LONG_TX" -gt 0 ]; then
echo "⚠️ $LONG_TX long-running transactions detected. Proceeding with caution."
fi
echo "=== Running migrations ==="
# For Elixir/Ecto:
mix ecto.migrate
# Verify migration status
echo "=== Post-migration status ==="
mix ecto.migrations
echo "✅ Migration complete"
Golden rule for zero-downtime migrations: Never remove a column or rename a column in a single deploy. Always use expand-contract:
Deploy 1: Add new column (nullable or with default)
Deploy 2: Backfill data, update code to write to both old and new
Deploy 3: Switch reads to new column
Deploy 4: Stop writing to old column
Deploy 5: Drop old column
6. Networking & Security
TLS / mTLS
cert-manager with Let's Encrypt
# cert-manager ClusterIssuer
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@towerops.net
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: nginx
# Or DNS challenge for wildcard certs:
- dns01:
cloudflare:
email: ops@towerops.net
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsZones:
- towerops.net
---
# Certificate resource (or use ingress annotation instead)
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: towerops-wildcard
namespace: production
spec:
secretName: towerops-wildcard-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- "*.towerops.net"
- towerops.net
mTLS Between Services (Istio / Linkerd)
# Istio PeerAuthentication — enforce mTLS for all services in namespace
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: production
spec:
mtls:
mode: STRICT # All traffic must be mTLS
---
# Or with Linkerd (simpler):
# Just annotate the namespace
apiVersion: v1
kind: Namespace
metadata:
name: production
annotations:
linkerd.io/inject: enabled # Auto-injects sidecar proxy with mTLS
Zero-Trust Networking
Principle: Never trust the network. Always verify identity.
# Kubernetes NetworkPolicy — default deny all
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
# Allow specific traffic
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: towerops-api-policy
namespace: production
spec:
podSelector:
matchLabels:
app: towerops-api
policyTypes:
- Ingress
- Egress
ingress:
# Allow from ingress controller only
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
ports:
- port: 4000
protocol: TCP
egress:
# Allow to PostgreSQL
- to:
- podSelector:
matchLabels:
app: postgresql
ports:
- port: 5432
protocol: TCP
# Allow to Redis
- to:
- podSelector:
matchLabels:
app: redis
ports:
- port: 6379
protocol: TCP
# Allow DNS
- to:
- namespaceSelector: {}
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
RBAC
Kubernetes RBAC
# Least-privilege service account for CI/CD deployments
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deployer
namespace: production
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deployer
namespace: production
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "update"]
- apiGroups: [""]
resources: ["configmaps", "secrets"]
verbs: ["get", "list", "create", "update", "patch"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deployer-binding
namespace: production
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: production
roleRef:
kind: Role
name: deployer
apiGroup: rbac.authorization.k8s.io
DNS Patterns
# DNS record types and when to use them
# A — Direct IP mapping (static servers)
# CNAME — Alias to another domain (CDN, load balancer)
# AAAA — IPv6 address
# MX — Mail routing
# TXT — Verification, SPF, DKIM, DMARC
# SRV — Service discovery (rare outside K8s)
# CAA — Certificate Authority Authorization (security)
# Example zone file for towerops.net
# Always set CAA to restrict who can issue certs
towerops.net. CAA 0 issue "letsencrypt.org"
towerops.net. CAA 0 issuewild "letsencrypt.org"
# Primary domains
towerops.net. A 203.0.113.10
www.towerops.net. CNAME towerops.net.
api.towerops.net. CNAME lb.towerops.net. # Points to load balancer
# Low TTL for things that might change during incidents
api.towerops.net. 300 CNAME lb.towerops.net. # 5 min TTL
# High TTL for stable records
towerops.net. 86400 A 203.0.113.10 # 24h TTL
# Email security
towerops.net. TXT "v=spf1 include:_spf.google.com ~all"
_dmarc.towerops.net. TXT "v=DMARC1; p=reject; rua=mailto:dmarc@towerops.net"
CDN Patterns
# Cloudflare as CDN + WAF + DDoS protection
# Key headers to set:
# Cache static assets aggressively
Cache-Control: public, max-age=31536000, immutable # For hashed assets (/app-abc123.js)
# Don't cache API responses
Cache-Control: private, no-store
# Don't cache HTML (or use short TTL)
Cache-Control: public, max-age=300, s-maxage=60 # Browser: 5min, CDN: 1min
# Purge cache (Cloudflare API)
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything": true}'
# Purge specific URLs
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"files": ["https://towerops.net/styles.css"]}'
7. Database Operations
PostgreSQL Backup — WAL Archiving
Continuous Archiving Setup
# postgresql.conf — WAL archiving settings
wal_level = replica # Required for archiving
archive_mode = on
archive_command = 'pgbackrest --stanza=main archive-push %p'
# Or with plain file copy:
# archive_command = 'test ! -f /backup/wal/%f && cp %p /backup/wal/%f'
max_wal_senders = 3 # For streaming replication
wal_keep_size = 1GB # Keep WAL segments for slow replicas
pgBackRest Configuration
# /etc/pgbackrest/pgbackrest.conf
[global]
repo1-path=/backup/pgbackrest
repo1-retention-full=4 # Keep 4 full backups
repo1-retention-diff=14 # Keep 14 differential backups
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<encryption-key>
compress-type=zst
compress-level=6
process-max=4 # Parallel backup processes
# S3 storage (alternative to local)
# repo1-type=s3
# repo1-s3-bucket=towerops-backups
# repo1-s3-region=us-east-1
# repo1-s3-endpoint=s3.amazonaws.com
[main]
pg1-path=/var/lib/postgresql/16/main
pg1-port=5432
# Initialize backup stanza
pgbackrest --stanza=main stanza-create
# Full backup (weekly cron)
pgbackrest --stanza=main --type=full backup
# Differential backup (daily cron)
pgbackrest --stanza=main --type=diff backup
# Verify backups
pgbackrest --stanza=main check
pgbackrest --stanza=main info
# Point-in-time recovery (PITR)
# Restore to specific timestamp
pgbackrest --stanza=main --type=time \
--target="2026-03-13 15:30:00-05" \
--target-action=promote \
restore
# Crontab
0 2 * * 0 pgbackrest --stanza=main --type=full backup # Weekly full at 2 AM Sunday
0 2 * * 1-6 pgbackrest --stanza=main --type=diff backup # Daily diff at 2 AM Mon-Sat
Backup Verification (Test Your Restores!)
#!/bin/bash
# scripts/verify-backup.sh — Run weekly, alert on failure
set -euo pipefail
RESTORE_DIR="/tmp/pg-restore-test"
rm -rf "$RESTORE_DIR"
echo "Restoring latest backup to temp directory..."
pgbackrest --stanza=main --pg1-path="$RESTORE_DIR" restore
echo "Starting temporary PostgreSQL instance..."
pg_ctl -D "$RESTORE_DIR" -o "-p 5433" -l /tmp/pg-restore.log start
echo "Running verification queries..."
psql -p 5433 -d towerops -c "SELECT count(*) FROM users;" || {
echo "❌ Backup verification FAILED"
pg_ctl -D "$RESTORE_DIR" stop
exit 1
}
echo "✅ Backup verification passed"
pg_ctl -D "$RESTORE_DIR" stop
rm -rf "$RESTORE_DIR"
PgBouncer — Connection Pooling
# pgbouncer.ini
[databases]
towerops = host=127.0.0.1 port=5432 dbname=towerops
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
# Pool settings
pool_mode = transaction # Best for web apps (release conn after each tx)
default_pool_size = 25 # Connections per user/database pair
max_client_conn = 1000 # Max incoming connections
min_pool_size = 5 # Keep warm connections
reserve_pool_size = 5 # Emergency overflow
reserve_pool_timeout = 3 # Wait before using reserve pool
# Timeouts
server_idle_timeout = 600 # Close idle server connections after 10 min
client_idle_timeout = 0 # No client idle timeout
query_timeout = 30 # Kill queries running > 30s
client_login_timeout = 60
# Logging
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
stats_period = 60
# TLS
client_tls_sslmode = prefer
client_tls_key_file = /etc/pgbouncer/server.key
client_tls_cert_file = /etc/pgbouncer/server.crt
# Connect to PgBouncer admin console
psql -p 6432 -U pgbouncer pgbouncer
# Useful admin commands:
SHOW POOLS; # Pool status per database/user
SHOW CLIENTS; # Connected clients
SHOW SERVERS; # Backend connections
SHOW STATS; # Request stats
SHOW CONFIG; # Current config
# Graceful restart (finishes active transactions)
RELOAD; # Reload config
PAUSE; # Pause new queries, finish active
RESUME; # Resume after pause
Zero-Downtime Migrations
Safe Migration Patterns
-- ❌ DANGEROUS: Locks entire table for write
ALTER TABLE devices ADD COLUMN firmware_version TEXT NOT NULL DEFAULT 'unknown';
-- ✅ SAFE: Add nullable column (instant, no lock)
ALTER TABLE devices ADD COLUMN firmware_version TEXT;
-- ✅ SAFE: Add default in separate step (PG 11+ is instant for defaults)
ALTER TABLE devices ALTER COLUMN firmware_version SET DEFAULT 'unknown';
-- ✅ SAFE: Backfill in batches, not all at once
DO $$
DECLARE
batch_size INT := 10000;
rows_updated INT;
BEGIN
LOOP
UPDATE devices
SET firmware_version = 'unknown'
WHERE id IN (
SELECT id FROM devices
WHERE firmware_version IS NULL
LIMIT batch_size
FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
RAISE NOTICE 'Updated % rows', rows_updated;
PERFORM pg_sleep(0.1); -- Brief pause to reduce load
COMMIT;
END LOOP;
END $$;
-- ✅ SAFE: Add NOT NULL constraint after backfill
ALTER TABLE devices ALTER COLUMN firmware_version SET NOT NULL;
-- ❌ DANGEROUS: CREATE INDEX locks writes
CREATE INDEX idx_devices_firmware ON devices(firmware_version);
-- ✅ SAFE: CONCURRENTLY doesn't block writes (takes longer)
CREATE INDEX CONCURRENTLY idx_devices_firmware ON devices(firmware_version);
-- ❌ DANGEROUS: Renaming column breaks running code
ALTER TABLE devices RENAME COLUMN name TO display_name;
-- ✅ SAFE: Use a view or add new column, migrate, drop old
-- Step 1: Add new column
ALTER TABLE devices ADD COLUMN display_name TEXT;
-- Step 2: Backfill (app writes to both during transition)
UPDATE devices SET display_name = name WHERE display_name IS NULL;
-- Step 3: (Next deploy) Switch reads to display_name
-- Step 4: (Next deploy) Stop writing to name
-- Step 5: (Next deploy) Drop old column
ALTER TABLE devices DROP COLUMN name;
Lock Timeout Safety Net
-- Always set lock_timeout for DDL in production
SET lock_timeout = '5s';
-- If we can't get the lock in 5s, fail rather than queue behind long transactions
BEGIN;
SET lock_timeout = '5s';
ALTER TABLE devices ADD COLUMN new_col TEXT;
COMMIT;
PostgreSQL Monitoring Queries
-- === Connection Health ===
-- Current connections by state
SELECT state, count(*)
FROM pg_stat_activity
WHERE pid != pg_backend_pid()
GROUP BY state
ORDER BY count DESC;
-- Long-running queries (potential problems)
SELECT pid, now() - pg_stat_activity.query_start AS duration,
query, state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE (now() - pg_stat_activity.query_start) > interval '5 minutes'
AND state != 'idle'
ORDER BY duration DESC;
-- Kill a stuck query
SELECT pg_cancel_backend(pid); -- Graceful (cancels query)
SELECT pg_terminate_backend(pid); -- Force (kills connection)
-- === Performance ===
-- Table bloat (dead tuples needing VACUUM)
SELECT schemaname, relname,
n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC
LIMIT 20;
-- Index usage (find unused indexes to drop)
SELECT schemaname, tablename, indexname,
idx_scan AS times_used,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
AND NOT indisunique -- Don't drop unique constraints
ORDER BY pg_relation_size(indexrelid) DESC;
-- Missing indexes (sequential scans on large tables)
SELECT relname, seq_scan, seq_tup_read,
idx_scan, idx_tup_fetch,
seq_tup_read / NULLIF(seq_scan, 0) AS avg_rows_per_seq_scan
FROM pg_stat_user_tables
WHERE seq_scan > 100
AND seq_tup_read / NULLIF(seq_scan, 0) > 10000
ORDER BY seq_tup_read DESC;
-- Slowest queries (requires pg_stat_statements extension)
SELECT query,
calls,
round(total_exec_time::numeric, 2) AS total_time_ms,
round(mean_exec_time::numeric, 2) AS avg_time_ms,
round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct_total
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
-- Cache hit ratio (should be > 99%)
SELECT
sum(heap_blks_read) AS heap_read,
sum(heap_blks_hit) AS heap_hit,
round(sum(heap_blks_hit) / NULLIF(sum(heap_blks_hit) + sum(heap_blks_read), 0)::numeric * 100, 2) AS hit_ratio
FROM pg_statio_user_tables;
-- === Replication ===
-- Replication status (on primary)
SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn,
(sent_lsn - replay_lsn) AS replication_lag
FROM pg_stat_replication;
-- Replication lag in seconds (on replica)
SELECT CASE WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()
THEN 0
ELSE EXTRACT(EPOCH FROM now() - pg_last_xact_replay_timestamp())
END AS replication_lag_seconds;
-- === Locks ===
-- Blocked queries (waiting for locks)
SELECT blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_statement,
blocking_activity.query AS blocking_statement
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.relation = blocked_locks.relation
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
-- === Disk Usage ===
-- Largest tables
SELECT relname,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
pg_size_pretty(pg_relation_size(relid)) AS table_size,
pg_size_pretty(pg_total_relation_size(relid) - pg_relation_size(relid)) AS index_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 20;
8. On-Call & Alerting
Alert Design Principles
Every alert must be:
- Actionable — Someone can do something about it right now
- Urgent — It needs attention within the response SLA
- Real — It indicates an actual problem (< 5% false positive rate)
- Unique — Not duplicated by another alert
Bad alerts → alert fatigue → ignored alerts → outages.
Alert Anti-Patterns
# ❌ BAD: Too sensitive, will flap
- alert: HighCPU
expr: node_cpu_usage > 80
for: 1m # 1 minute is too short
# ✅ GOOD: Sustained high CPU
- alert: HighCPU
expr: node_cpu_usage > 85
for: 15m # Must sustain for 15 min
# ❌ BAD: Not actionable
- alert: DiskUsageAbove50Percent
expr: disk_usage > 50 # What am I supposed to do at 50%?
# ✅ GOOD: Predictive and actionable
- alert: DiskWillFillIn24h
expr: predict_linear(disk_free[6h], 24*3600) < 0
# Clear action: expand disk or clean up
annotations:
runbook_url: "https://wiki/runbooks/disk-full"
# ❌ BAD: Causes alert storm (one per pod)
- alert: PodHighMemory
expr: container_memory_usage > 100Mi
# ✅ GOOD: Aggregate and threshold appropriately
- alert: NamespaceHighMemory
expr: |
sum(container_memory_usage_bytes{namespace="production"})
/ sum(kube_resourcequota{resource="limits.memory", namespace="production"})
> 0.85
Escalation Policy
┌─────────────────────────────────────────────────────────┐
│ Time │ Who Gets Paged │ Channel │
├─────────────────────────────────────────────────────────┤
│ 0 min │ Primary on-call │ PagerDuty + SMS │
│ 10 min │ Secondary on-call │ PagerDuty + SMS │
│ 20 min │ Engineering manager │ Phone call │
│ 30 min │ VP Engineering │ Phone call │
│ 45 min │ CTO │ Phone call │
└─────────────────────────────────────────────────────────┘
SEV-1: Start at top, escalate every 10 min if not acknowledged
SEV-2: Start at primary on-call, escalate after 30 min
SEV-3: Slack notification only, addressed next business day
SEV-4: Ticket, addressed during sprint planning
Runbook Template
# Runbook: [Alert Name]
## Alert
- **Name:** HighErrorBudgetBurn_Fast
- **Severity:** Critical
- **Dashboard:** [Link to Grafana dashboard]
- **SLO:** 99.9% availability, 30d rolling
## What This Means
The API error rate is consuming our error budget at 14.4x the sustainable rate.
At this burn rate, we'll exhaust the entire monthly budget within ~2 hours.
## Impact
- Users may see 5xx errors on API calls
- Dependent services (mobile app, dashboard) will be degraded
## Diagnosis Steps
1. **Check deployment history:**
```bash
kubectl rollout history deployment/towerops-api -n production
# Was there a recent deploy? If so, rollback is likely the fix.
```
2. **Check error logs:**
```bash
kubectl logs -l app=towerops-api -n production --tail=50 | grep -i error
```
3. **Check dependencies:**
```bash
# Database
psql "$DATABASE_URL" -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state;"
# Redis
redis-cli -h redis ping
# External APIs
curl -s -o /dev/null -w '%{http_code}' https://api.stripe.com/v1/charges -H "Authorization: Bearer $STRIPE_KEY"
```
4. **Check resource usage:**
```bash
kubectl top pods -n production -l app=towerops-api
```
## Mitigation
### If caused by a recent deployment:
```bash
kubectl rollout undo deployment/towerops-api -n production
kubectl rollout status deployment/towerops-api -n production
```
### If caused by traffic spike:
```bash
kubectl scale deployment/towerops-api -n production --replicas=10
```
### If caused by database issues:
```bash
# Kill long-running queries
psql "$DATABASE_URL" -c "SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE state = 'active' AND now() - query_start > interval '1 minute';"
```
### If caused by external dependency:
- Enable circuit breaker / fallback mode
- Update status page
## Resolution Verification
```bash
# Verify error rate is back to normal
curl -s "http://prometheus:9090/api/v1/query?query=sum(rate(http_requests_total{status_code=~\"5..\"}[5m]))/sum(rate(http_requests_total[5m]))"
# Verify pods are healthy
kubectl get pods -n production -l app=towerops-api
```
## Escalation
If not resolved within 30 minutes, escalate to the team lead.
## Related
- [Architecture diagram](link)
- [Previous incidents](link)
- [Service dependencies](link)
Blameless Postmortem Template
# Postmortem: [Incident Title]
**Date:** 2026-03-13
**Duration:** 14:30 – 15:15 CDT (45 minutes)
**Severity:** SEV-2
**Authors:** [Names of postmortem authors]
**Status:** Action items in progress
## Summary
Brief, 2-3 sentence description. What happened, who was impacted, how was it resolved.
> A misconfigured database migration caused connection exhaustion in the PostgreSQL
> connection pool, resulting in 503 errors for 40% of API requests over 45 minutes.
> Resolved by rolling back the migration and restarting PgBouncer.
## Impact
- **Users affected:** ~2,400 (40% of active users)
- **Revenue impact:** ~$3,200 in failed transactions
- **Error budget consumed:** 8.3% of monthly budget
- **Support tickets:** 47
## Timeline (all times CDT)
| Time | Event |
|------|-------|
| 14:28 | Deploy #847 rolls out (includes DB migration) |
| 14:30 | `HighErrorBudgetBurn_Fast` alert fires |
| 14:32 | On-call engineer (Alice) acknowledges alert |
| 14:35 | Alice identifies high 503 rate, checks recent deploys |
| 14:38 | Correlation with deploy #847 confirmed |
| 14:40 | Alice initiates rollback of deploy #847 |
| 14:42 | Rollback complete, but errors persist |
| 14:45 | Bob (secondary) joins, identifies PgBouncer pool exhaustion |
| 14:48 | PgBouncer restarted, connection pool drains |
| 14:52 | Error rate drops to normal |
| 15:00 | Monitoring confirms sustained recovery |
| 15:15 | Incident declared resolved |
## Root Cause
The migration added a `NOT NULL` constraint to a heavily-used column, which acquired
an `ACCESS EXCLUSIVE` lock on the `devices` table. This blocked all queries touching
the table, causing connection pool exhaustion as requests queued up waiting for the lock.
The migration was tested in staging, but staging has ~1% of production data and the
lock was released in < 1 second. In production with 2.3M rows, the lock validation
took > 5 minutes.
## Contributing Factors
1. No lock timeout set for DDL operations in migration scripts
2. Staging data volume doesn't reflect production
3. Migration ran inside the application deploy, not as a separate step
4. PgBouncer didn't release connections after the migration was rolled back (stale pool state)
## What Went Well
- Alert fired within 2 minutes of impact starting
- On-call responded in under 5 minutes
- Rollback procedure worked as documented
- Team communicated clearly in #incidents channel
## What Went Wrong
- Migration wasn't tested against production-like data volume
- Rollback didn't fully resolve the issue (PgBouncer needed manual intervention)
- Status page wasn't updated until 15 minutes into the incident
## Action Items
| Action | Owner | Priority | Ticket | Due |
|--------|-------|----------|--------|-----|
| Add `SET lock_timeout = '5s'` to all migration scripts | Alice | P1 | OPS-234 | 2026-03-17 |
| Create production-sized staging dataset (anonymized) | Bob | P2 | OPS-235 | 2026-03-27 |
| Separate migration step from application deploy in CI/CD | Carol | P2 | OPS-236 | 2026-03-27 |
| Add PgBouncer pool drain to rollback runbook | Alice | P3 | OPS-237 | 2026-03-20 |
| Automate status page updates on SEV-1/2 alerts | Dave | P3 | OPS-238 | 2026-04-03 |
## Lessons Learned
1. **DDL operations on large tables need special handling.** Always use `SET lock_timeout`,
always test against production-scale data, always run migrations separately from deploys.
2. **Connection poolers have state.** Rolling back the root cause doesn't always clear
downstream effects. Include dependent service restarts in rollback procedures.
3. **Staging ≠ Production.** When the difference in data volume is 100x, some issues
will only manifest in production. Consider periodic production data snapshots for staging.
---
*This postmortem is blameless. It focuses on systemic improvements, not individual fault.
The goal is to prevent recurrence, not assign blame.*
Quick Reference Cards
The RED Method (Request-driven services)
R — Rate: Request throughput (req/s)
E — Errors: Failed requests (count or rate)
D — Duration: Request latency (histograms)
The USE Method (Infrastructure resources)
U — Utilization: % time resource is busy
S — Saturation: Queue depth / backlog
E — Errors: Error events on the resource
The Four Golden Signals (Google SRE)
1. Latency — Time to serve a request
2. Traffic — Demand on the system (req/s)
3. Errors — Rate of failed requests
4. Saturation — How "full" the system is
Emergency Commands
# === Kubernetes ===
kubectl rollout undo deployment/NAME -n NAMESPACE # Rollback deploy
kubectl scale deployment/NAME --replicas=N -n NAMESPACE # Scale manually
kubectl delete pod NAME -n NAMESPACE # Force restart pod
kubectl cordon NODE # Stop scheduling
kubectl drain NODE --ignore-daemonsets # Evacuate node
# === PostgreSQL ===
# Kill all active queries on a table
SELECT pg_cancel_backend(pid) FROM pg_stat_activity
WHERE query LIKE '%tablename%' AND state = 'active';
# Kill all connections to a database
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = 'dbname' AND pid <> pg_backend_pid();
# Emergency VACUUM (when autovacuum is too slow)
VACUUM (VERBOSE, PARALLEL 4) tablename;
# === System ===
# Find what's eating disk
du -h --max-depth=2 / | sort -rh | head -20
# Find what's eating CPU
ps aux --sort=-%cpu | head -10
# Find what's eating memory
ps aux --sort=-%mem | head -10
# Network: who's connected
ss -tunlp # Listening ports
ss -tun state established # Active connections
Last updated: 2026-03-13 Maintained by: TowerOps SRE Team