Raxx · internal docs

internal · gated

CI Monitoring runbook

System: ci-monitoring (Prometheus + Alertmanager + Grafana + blackbox_exporter) Owner: sre-agent / Kristerpher Terraform root: terraform/ci-monitoring/ Card: #1022 ADR: ADR-0134 (Woodpecker CI) Last reviewed: 2026-07-08 Last incident: 2026-07-08 (see RCA — cloud-init curl crash + Grafana secrets perms)


Overview

A dedicated EC2 t4g.small (arm64, us-east-2) running a docker-compose monitoring stack:

Component Port (localhost-only) Purpose
Prometheus 9090 Scrape + rule evaluation
Alertmanager 9093 Alert routing to email
Grafana 3000 Dashboard UI
blackbox_exporter 9115 HTTP/TLS probes
node_exporter 9100 Host metrics (monitoring box)

None of these ports are open to the internet. Access is via SSM port forwarding.

This stack complements — it does NOT replace — the external GHA watchdog (.github/workflows/woodpecker-healthcheck.yml). The GHA watchdog provides independent-vantage liveness detection from outside the VPC.


Accessing the UI

Pre-requisite: AWS SSM Session Manager plugin installed locally.

brew install session-manager-plugin   # macOS

Grafana (dashboards)

aws ssm start-session \
  --region us-east-2 \
  --target <instance-id> \
  --document-name AWS-StartPortForwardingSession \
  --parameters '{"portNumber":["3000"],"localPortNumber":["3000"]}'
# then open http://localhost:3000 in your browser
# username: admin
# password: retrieve from SSM:
aws ssm get-parameter --region us-east-2 \
  --name /ci/monitoring/grafana_admin_password \
  --with-decryption --query Parameter.Value --output text

The instance ID is in the Terraform output instance_id.

Prometheus

aws ssm start-session \
  --region us-east-2 \
  --target <instance-id> \
  --document-name AWS-StartPortForwardingSession \
  --parameters '{"portNumber":["9090"],"localPortNumber":["9090"]}'
# then open http://localhost:9090

Alertmanager

aws ssm start-session \
  --region us-east-2 \
  --target <instance-id> \
  --document-name AWS-StartPortForwardingSession \
  --parameters '{"portNumber":["9093"],"localPortNumber":["9093"]}'
# then open http://localhost:9093

Post-apply configuration (one-time operator actions)

After terraform apply completes, complete these steps in order:

Step 1 — Install node_exporter on the Woodpecker server

Copy-paste the node_exporter_install_command from terraform output and run it. It uses SSM Run Command to install node_exporter v1.8.2 on the WP server without SSH.

Monitor the command execution:

COMMAND_ID=<output from send-command>
aws ssm list-command-invocations \
  --region us-east-2 \
  --command-id "$COMMAND_ID" \
  --details \
  --query "CommandInvocations[0].CommandPlugins[0].Output"

Step 2 — Configure Woodpecker to expose /metrics

Retrieve the Prometheus bearer token:

WP_TOKEN=$(aws ssm get-parameter \
  --region us-east-2 \
  --name /ci/monitoring/wp_prometheus_token \
  --with-decryption \
  --query Parameter.Value \
  --output text)

On the Woodpecker server, add these two environment variables to the WP server configuration (wherever WOODPECKER_* vars are currently set — typically the systemd unit file or the docker-compose that runs the WP server):

WOODPECKER_METRICS_SERVER_ADDR=:9001
WOODPECKER_PROMETHEUS_AUTH_TOKEN=<value of $WP_TOKEN>

Then restart the Woodpecker server. The Prometheus target woodpecker will transition from DOWN to UP within 30 seconds.

Step 3 — Verify all Prometheus targets are UP

Open Prometheus (port 9090 via SSM forward) → Status → Targets. Expected state after post-apply setup:

Job Targets Expected state
prometheus localhost:9090 UP
node-monitoring node-exporter:9100 UP
node-woodpecker 10.42.10.143:9100 UP
woodpecker 10.42.10.143:9001 UP
blackbox-http ci.moosequest.net/api/version, api.raxx.app/health UP
blackbox-tls ci.moosequest.net:443 UP

Step 4 — Fire a test alert

Send a test alert through Alertmanager to verify email delivery:

# SSM forward to port 9093 first, then:
curl -s -X POST http://localhost:9093/api/v2/alerts \
  -H "Content-Type: application/json" \
  -d '[{
    "labels": {
      "alertname": "TestAlert",
      "severity": "warning",
      "instance": "ci-monitoring-test"
    },
    "annotations": {
      "summary": "Test alert from ci-monitoring runbook",
      "description": "This is a manual test alert. Resolve by sending an empty array to the same endpoint."
    },
    "endsAt": "'"$(date -u -v+5M '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -d '+5 minutes' '+%Y-%m-%dT%H:%M:%SZ')"'"
  }]'

Expect an email at kris@moosequest.net within 30 seconds. Check spam if it doesn't arrive. To resolve the test alert early: POST an empty array [] to the same endpoint.

Step 5 — Retire the GHA watchdog (deferred)

Keep the GHA watchdog (.github/workflows/woodpecker-healthcheck.yml) running until this stack's alerting is verified end-to-end (Steps 3 + 4 complete, at least one real alert received and resolved). Once verified, the watchdog can be disabled or left as a cheap second opinion from outside the VPC.


How to tell it's broken


How to diagnose (in order)

  1. Check container status:
# SSM shell session:
aws ssm start-session --region us-east-2 --target <instance-id>

# On the box:
cd /opt/monitoring
docker compose ps
docker compose logs --tail=50 prometheus
docker compose logs --tail=50 alertmanager
docker compose logs --tail=50 grafana
  1. Check bootstrap log:
cat /var/log/ci-monitoring-bootstrap.log
  1. Check Prometheus targets: Prometheus UI → Status → Targets

  2. Check alert rules: Prometheus UI → Alerts

  3. Check Alertmanager config:

curl -s http://localhost:9093/api/v2/status | python3 -m json.tool

Known failure modes

Failure mode A: Woodpecker /metrics target DOWN

Symptom: woodpecker job shows DOWN in Prometheus targets.

Cause A1: WOODPECKER_METRICS_SERVER_ADDR is not set on WP server. Fix A1: Set env var :9001 on WP server and restart it.

Cause A2: Bearer token mismatch. Fix A2: Retrieve the token from SSM and compare with what's set on the WP server:

aws ssm get-parameter --region us-east-2 \
  --name /ci/monitoring/wp_prometheus_token \
  --with-decryption --query Parameter.Value --output text

Cause A3: SG rule not applied — monitoring SG lacks inbound access to port 9001 on WP SG. Fix A3: terraform apply should have created aws_security_group_rule.wp_metrics_from_monitoring. Verify:

aws ec2 describe-security-groups --region us-east-2 --group-ids sg-044249808bc11eaa7 \
  --query "SecurityGroups[0].IpPermissions[?FromPort==\`9001\`]"

Verification: Prometheus target woodpecker transitions to UP within 30s.


Failure mode B: node_exporter target DOWN on WP server

Symptom: node-woodpecker job shows DOWN.

Cause B1: node_exporter not installed on WP server. Fix B1: Run the node_exporter_install_command from terraform output.

Cause B2: node_exporter installed but not running. Fix B2:

aws ssm send-command \
  --region us-east-2 \
  --instance-ids i-082ee835595d90ae0 \
  --document-name AWS-RunShellScript \
  --parameters 'commands=["systemctl status node_exporter; journalctl -u node_exporter -n 20"]'

Cause B3: SG rule missing (port 9100 not open from monitoring SG to WP SG). Fix B3: Verify aws_security_group_rule.wp_node_exporter_from_monitoring exists:

aws ec2 describe-security-groups --region us-east-2 --group-ids sg-044249808bc11eaa7 \
  --query "SecurityGroups[0].IpPermissions[?FromPort==\`9100\`]"

If missing, run terraform apply from terraform/ci-monitoring/.

Verification: node-woodpecker target UP.


Failure mode C: Alertmanager not sending email

Symptom: Alert fires in Prometheus, appears in Alertmanager, but no email arrives.

Cause C1: Postmark token expired or invalid. Fix C1:

# Check current token
aws ssm get-parameter --region us-east-2 \
  --name /ci/monitoring/postmark_server_token \
  --with-decryption --query Parameter.Value --output text

# Update if rotated:
aws ssm put-parameter --region us-east-2 \
  --name /ci/monitoring/postmark_server_token \
  --type SecureString \
  --value "NEW_TOKEN" \
  --overwrite >/dev/null

# Regenerate alertmanager.yml with new token and reload:
# (on the monitoring box)
cd /opt/monitoring
# Edit alertmanager/alertmanager.yml and replace the smtp_auth_password
docker compose exec alertmanager amtool check-config /etc/alertmanager/alertmanager.yml
docker compose kill -s HUP alertmanager

Cause C2: Email in spam / Postmark inactive sender signature. Fix C2: Check Postmark dashboard for delivery status. Ensure no-reply@raxx.app has an active sender signature.

Verification: amtool alert add sends a test, email arrives within 60s.


Failure mode D: Grafana restart loop (secrets permission denied)

Symptom: docker compose ps shows grafana in Restarting state. Logs show /run/secrets/grafana_admin_password: Permission denied.

Cause: The secrets file /opt/monitoring/secrets/grafana_admin_password is owned by root with mode 600. The Grafana container runs as UID 472 (non-root) and cannot read it.

Fix:

chmod 644 /opt/monitoring/secrets/grafana_admin_password
cd /opt/monitoring && docker compose up -d grafana

Verification: docker compose ps grafana shows Up (not Restarting).


Failure mode E: Grafana shows "Data source not found"

Symptom: Grafana dashboards show "No data" or datasource error.

Cause: Prometheus container restarted with a new container IP, breaking the http://prometheus:9090 URL.

Fix: Restart grafana — Docker's internal DNS will re-resolve:

cd /opt/monitoring && docker compose restart grafana

Verification: Dashboards show data within 60s.


Failure mode F: Monitoring box ran out of disk

Symptom: DiskUsageHigh alert fires on instance=ci-monitoring. Prometheus may stop ingesting (writes fail when disk is full).

Fix:

# Check retention setting (currently 15 days):
docker compose exec prometheus \
  sh -c 'du -sh /prometheus && df -h /prometheus'

# Clear old data if urgent (preserves last 7 days):
docker compose exec prometheus sh -c \
  'find /prometheus -name "*.tmp" -delete'

# Long-term: reduce retention in docker-compose.yml:
#   --storage.tsdb.retention.time=7d
# then: docker compose up -d prometheus

Verification: DiskUsageHigh resolves.


Failure mode G: cloud-init crash — bootstrap stops at package install

Symptom: /var/log/ci-monitoring-bootstrap.log stops partway through — e.g. curl(23) error or abrupt stop during docker-compose install. Stack never starts. The instance was tainted and re-provisioned, but the bug is in cloud-init.sh.tpl.

Cause: set -euo pipefail at the top of the bootstrap causes any non-zero exit to abort the entire script. Known trigger: curl writing to a path where the parent directory doesn't exist (e.g. /usr/local/lib/docker/cli-plugins/ before mkdir).

Fix: In terraform/ci-monitoring/templates/cloud-init.sh.tpl, the docker-compose install now uses dnf install -y docker-compose-plugin as primary method (available on AL2023), with mkdir -p before the curl fallback. This was fixed in PR #4084.

To re-run bootstrap on existing instance without taint (for testing):

# SSM shell on the box:
bash /var/lib/cloud/instance/scripts/part-001

Verification: Bootstrap log ends with [ci-monitoring] completed at ....


Updating configs

Configs live in /opt/monitoring/ on the instance. To update:

  1. Make config change via SSM shell or update cloud-init.sh.tpl + re-provision.
  2. For Prometheus config changes (new scrape target, rule update):
cd /opt/monitoring
# Edit prometheus/prometheus.yml or prometheus/rules/alerts.yml
docker compose exec prometheus sh -c \
  'kill -HUP 1'  # sends SIGHUP to reload config without restart
  1. For Alertmanager config changes:
cd /opt/monitoring
# Edit alertmanager/alertmanager.yml
docker compose kill -s HUP alertmanager
  1. For Grafana changes (new dashboard, datasource), place JSON in /opt/monitoring/grafana/dashboards/ and wait up to 30s for Grafana's provisioning watcher to pick it up.

Emergency stop

# SSM session on monitoring box:
cd /opt/monitoring
docker compose down

# To prevent auto-restart on reboot:
systemctl disable ci-monitoring.service

This does not affect the Woodpecker CI server.


Cost estimate

Resource Type Est. monthly cost
EC2 t4g.small (us-east-2) On-demand ~$13.36/mo
EBS gp3 20GB root volume Storage ~$1.60/mo
Data transfer (outbound) Minimal (SMTP only from VPC) ~$0.10/mo
Total ~$15/mo

t4g.small On-Demand: $0.0184/hr × 730h = ~$13.43/mo in us-east-2 (2026). An additional Reserved Instance (1-year, no upfront) reduces this to ~$8.30/mo.


Escalation

Wake the operator (kris@moosequest.net) when: - All Prometheus targets are DOWN (monitoring box itself is unreachable) - Alertmanager has been unable to send email for > 1 hour - The monitoring box has been unreachable via SSM for > 15 minutes - Disk is > 95% on the monitoring box


Known open items (punch-list)

Item Status Notes
WP /metrics wiring DEFERRED Requires WOODPECKER_METRICS_SERVER_ADDR=:9001 + WOODPECKER_PROMETHEUS_AUTH_TOKEN on WP server, then server restart. Do during a no-active-pipeline window. See Step 2 in Post-apply configuration.
Grafana secrets chmod in cloud-init FIXED in PR #4084 chmod 644 so Grafana UID 472 can read the file
Acceptance gate test email CONFIRMED 2026-07-08 [Raxx CI] TestAlert FIRING (CRITICAL) received at kris@moosequest.net

References