ADR-0137 — Woodpecker external config service: O(1) pipeline config fetch with SHA cache
Status: Proposed
Date: 2026-07-10 UTC
Deciders: Kristerpher (operator); software-architect (design)
Scope: Woodpecker CI server on EC2 i-082ee835595d90ae0 (us-east-2, acct 521228113048);
new wp-config-svc process co-located on the same host
Refs: ADR-0134 (self-hosted EC2 decision); ADR-0135 (WP migration phases 3–5);
docs/incidents/2026-07-09-wp-forge-context-deadline.md (incident that drove this);
Failure mode B-2 in docs/ops/runbooks/ci-woodpecker.md
Context
On 2026-07-09, the Woodpecker CI server's Go HTTP connection pool accumulated stale
half-open TCP connections after ~5 days of uptime. This caused context deadline exceeded
errors when the server tried to fetch pipeline config from GitHub, blocking all pipeline
creation for ~45 minutes and delaying the v1.10.3 production deploy.
The mitigation — raising WOODPECKER_FORGE_TIMEOUT to 120s and scheduling a weekly server
restart — is runway, not a cure. The underlying problem is structural:
On every push or pull_request webhook event, Woodpecker performs a live, sequential
GitHub Contents API fetch to load pipeline config:
- 1 call: list the .woodpecker/ directory
- 79 calls: fetch each file's content (one per pipeline file)
- Total: 80 sequential HTTPS round-trips per webhook event
- Typical serial latency: 80 × ~100ms = ~8 seconds against GitHub API from EC2 us-east-2
This is O(N) with pipeline-file count. Every feature PR pays it. Any GitHub API jitter
compounds serially. The original 10s WOODPECKER_FORGE_TIMEOUT had no safety margin; the
raised 120s limit is generous but does nothing to reduce the number of API calls.
Woodpecker supports a first-class escape hatch: WOODPECKER_CONFIG_SERVICE_ENDPOINT.
When set, WP POSTs the repo + SHA + netrc context to an HTTP endpoint instead of fetching
config from the forge. The endpoint returns the resolved pipeline configs in one response,
replacing the per-file forge fetch entirely.
The permanent fix is to build and operate this external config service (wp-config-svc).
Invariants
- No stored credentials. The config service reads pipeline YAML only. It holds no user credentials or session tokens.
- Credentials into infra, not code. The GitHub App private key (PEM) and the WP signature public key live in SSM SecureString only. Never committed to the repo.
- Audit trail. Config service logs every request: repo, commit SHA, cache hit/miss, GitHub API calls made, response status. Logs go to CloudWatch.
- Fallback is not optional. The config service must never become a new single point of
failure. If
wp-config-svcis unreachable or returns non-200, Woodpecker falls back to the native forge fetch natively. This is enforced by Woodpecker's config-service protocol.
Decision
Build and co-locate wp-config-svc on the WP EC2 host as a docker-compose service,
using the Git Trees API with parallel blob fetching and an in-process SHA-keyed immutable
cache. Auth to GitHub via a dedicated GitHub App installation token stored in SSM. Roll out
in shadow mode (HTTP 204 pass-through) first, then cut over.
Each component of this decision is detailed below.
Data flow
GitHub webhook POST → ci.moosequest.net/hook (ALB → WP server :8000)
│
▼
WP server processes webhook, needs pipeline config
│
├── [WOODPECKER_CONFIG_SERVICE_ENDPOINT set]
│ │
│ ▼
│ POST http://localhost:8095/config
│ Body: {repo, sha, netrc, configs: []}
│ Auth: Authorization: Bearer <JWT signed by WP Ed25519 key>
│ │
│ ▼
│ wp-config-svc (on same host, :8095)
│ │
│ ├── [SHA cache hit] ──────────────────────────────┐
│ │ │
│ └── [SHA cache miss] │
│ │ │
│ ▼ │
│ 1. GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1
│ (GitHub API — one call, lists full tree with blob SHAs)
│ │ │
│ ▼ │
│ 2. Filter: entries where path starts with .woodpecker/│
│ │ │
│ ▼ │
│ 3. N parallel GET /repos/{owner}/{repo}/git/blobs/{sha}
│ (GitHub API — N concurrent async HTTP requests) │
│ │ │
│ ▼ │
│ 4. Decode base64 blob content, parse YAML │
│ │ │
│ 5. Cache result keyed on commit SHA (immutable) │
│ │ ▼
│ HTTP 200 → {"configs": [{name, data}, ...]} ←───────┘
│ │
│ ▼ (on non-200 / timeout from ECS)
└── WP native forge fetch (fallback — O(N) sequential, 120s timeout)
Config retrieval strategy
Decision: Git Trees API (1 call) + parallel blob fetches (N concurrent) + SHA cache
Three options evaluated:
Option A — Directory listing + sequential blob fetch (current WP native behavior) 80 sequential HTTPS calls, ~8s at 100ms/call. Eliminated — this is the problem.
Option B — Tarball endpoint (GET /repos/{owner}/{repo}/tarball/{sha})
One HTTP call downloads the entire repo as a .tar.gz archive, then extract only
.woodpecker/* files. One API call consumed; tarball size for this repo is estimated at
5–15 MB (large: Python + migrations + frontend). Download time ~400ms–1.2s plus
decompression overhead. Wastes bandwidth on non-pipeline files (ratio: ~400 KB wanted vs
~10 MB transferred). Not recommended.
Option C — Git Trees API + parallel blob fetches (RECOMMENDED)
- GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1 returns the full tree in one
API call (~30 KB JSON response). Parses blob SHAs for all .woodpecker/*.yaml entries.
- N parallel GET /repos/{owner}/{repo}/git/blobs/{blob_sha} requests fired concurrently
via async HTTP (aiohttp). Effective latency = max(single blob roundtrip) ≈ 100–200ms
regardless of N.
- Total uncached: 1 tree call (~100ms) + parallel blobs (~100–200ms) = ~200–300ms total.
- 79 blobs × ~5 KB each = ~395 KB transferred. Efficient.
SHA-keyed immutable cache:
A commit SHA maps to exactly one set of .woodpecker/ file contents, forever. Cache
structure: dict[str, list[Config]] in-process, keyed on {repo_full_name}:{commit_sha}.
Entries never expire (SHA is immutable). Process restart clears cache; cold-start recovery
is trivial (next cache miss re-fetches). Cache size: 79 files × ~5 KB × N cached SHAs.
For 100 distinct SHAs cached: ~40 MB. Acceptable for the t4g.small host (4 GB RAM). Apply
an LRU cap at 500 entries if memory becomes a concern.
API rate-limit accounting: - Per uncached event: 1 tree + 79 blob calls = 80 API calls - GitHub App installation token: 15,000 req/hr (vs 5,000/hr OAuth App — see Auth section) - Budget for cold events (GitHub App): 15,000 / 80 = 187 cold events/hr - Expected cache hit rate: >90% (most branch pushes share a recently-cached SHA or branch tip moves one commit at a time — prior SHA already cached from the preceding push event) - Practical cold-event rate: <20/hr in normal development — well within any rate limit
Scale math
| Metric | Before | After (cache miss) | After (cache hit) |
|---|---|---|---|
| GitHub API calls per webhook event | 80 (serial) | 80 (1 tree + 79 parallel) | 0 |
| Wall-clock latency | ~8,000 ms | ~200–300 ms | ~1 ms |
| Speedup vs baseline | — | ~27× | ~8,000× |
| FORGE_TIMEOUT headroom | 2 s (10s limit, 8s cost) | 119.7 s (120s limit, 0.3s cost) | 119.999 s |
| GitHub API calls/hr ceiling (GitHub App) | 62 events/hr at 80 calls | 187 cold events/hr | unlimited |
| O(N) growth with pipeline file count | yes — serial × latency | yes — parallel, 1× latency | no |
The O(N) growth on the cold path remains, but parallel vs serial changes the effective
cost from N × latency to 1 × latency. Adding pipeline files no longer degrades
wall-clock time materially.
Where it runs
Co-located on the same EC2 instance as Woodpecker server (i-082ee835595d90ae0),
as a new service in the existing docker-compose at /opt/woodpecker/docker-compose.yml.
Why co-located, not cross-box: - WP→ECS call latency: 0 ms (loopback) vs ~1 ms (same VPC) vs ~10 ms (cross-AZ). - ECS downtime is not a new single point of failure. WP natively falls back to forge fetch if ECS returns non-200. The slow forge path (with 120s timeout) remains the safety net. ECS dying = CI returns to pre-fix behavior (slow), not to broken CI. - No extra EC2 cost: additional docker-compose service on a host with available headroom. - Operational simplicity: one deployment unit, one SSM-backed config, one restart sequence. - The "ECS dies when box dies" concern is moot: if the EC2 box dies, WP also dies and CI is down regardless of ECS. Co-location adds zero marginal outage risk.
Network binding: wp-config-svc binds to 127.0.0.1:8095 only. WP connects to
http://localhost:8095/config. No ALB rule changes, no security group changes, no public
exposure, no DNS entry.
Auth
WP → ECS: Ed25519 JWT signature verification
Woodpecker signs each POST to the config service endpoint with a JWT using an Ed25519
private key held internally by the WP server. The corresponding public key is exposed at:
GET https://ci.moosequest.net/api/signature/public-key
On startup, wp-config-svc fetches and caches this public key (WP admin token required;
see Open question 2). Each incoming POST is rejected (HTTP 400) unless the JWT in the
Authorization: Bearer <token> header is valid against this key. This prevents config
injection from any process that happens to reach localhost:8095.
No new WP configuration is required for the signature — it is automatic when
WOODPECKER_CONFIG_SERVICE_ENDPOINT is set.
ECS → GitHub: dedicated GitHub App installation token
Provision a GitHub App named raxx-ci-config with a single permission:
Repository contents: read-only. Install it on raxx-app/TradeMasterAPI.
SSM parameters to create under /ci/config-svc/ (SecureString):
- /ci/config-svc/github-app-id
- /ci/config-svc/github-app-installation-id
- /ci/config-svc/github-app-private-key (PEM, multi-line)
The service mints an installation access token (POST /app/installations/{id}/access_tokens)
at startup and refreshes it 5 minutes before the 1-hour expiry. The token is held in
memory only; never written to disk or logs.
Why a dedicated App vs reusing the WP OAuth App token:
GitHub App installation tokens provide 15,000 req/hr per installation vs the OAuth App's
5,000/hr. The WP forge OAuth App (/ci/woodpecker/github-client-id) is also used by WP
for webhook delivery, status checks, and user authentication — sharing its rate limit
bucket with the config service creates contention. A dedicated App with contents:read
scopes narrowly and has an independent limit.
This is complementary to the bot App token consolidation tracked separately. The
raxx-ci-config App is a server-side infra identity, not an agent pipeline identity.
Fallback if App provisioning is delayed: Use the existing WP OAuth App credentials
(/ci/woodpecker/github-client-id + github-client-secret) in the interim. Acceptable
for Phase 0 shadow mode where the ECS makes no production-path API calls that matter.
Upgrade to the dedicated App before Phase 1 cutover.
Signature verification detail
# On startup — fetch WP's Ed25519 public key:
# (ALB bypass rule for /api/signature/* required — see Open question 2)
resp = httpx.get(
"https://ci.moosequest.net/api/signature/public-key",
headers={"Authorization": f"Bearer {WP_ADMIN_TOKEN}"}
)
WP_VERIFY_KEY = load_pem_public_key(resp.text.encode())
# On each POST /config:
def verify_wp_request(authorization: str) -> bool:
token = authorization.removeprefix("Bearer ")
try:
jwt.decode(token, WP_VERIFY_KEY, algorithms=["EdDSA"])
return True
except jwt.InvalidTokenError:
return False
# Reject with HTTP 400 if verification fails; log source host + rejection reason.
No new SSM parameters needed for the WP public key — it is read from the live WP server. The key changes only if WP is fully re-provisioned (rare); cache refresh on service restart is sufficient.
Rollout plan
Phase 0 — Shadow mode (soak before cutover)
wp-config-svc starts and binds to 127.0.0.1:8095
WOODPECKER_CONFIG_SERVICE_ENDPOINT=http://localhost:8095/config set in WP docker-compose
ECS returns HTTP 204 on every request → WP falls back to native forge fetch
ECS simultaneously runs the full GitHub API path (tree + parallel blobs + cache)
Logs emitted: "shadow: computed N configs for {sha}, cache_hit={bool}, latency={ms}"
WP behavior: unchanged; production unaffected; forge fetch still happens
Success criteria: 20+ successful shadow runs across push and pull_request events;
ECS output (config names + content SHA) matches WP forge fetch output for each event
Phase 1 — Active mode cutover
ECS returns HTTP 200 with computed configs instead of 204
WP uses ECS output for pipeline config; forge fetch no longer runs per event
WOODPECKER_FORGE_TIMEOUT can be lowered to 30s (ECS path ~300ms; fallback still needed)
Monitor for 48 hours: ECS p99 latency, cache hit rate, GitHub API error rate, fallback rate
Success criteria: zero forge fallbacks, ECS latency p99 < 500ms, no pipeline creation errors
Phase 2 — Steady state
Remove weekly server restart workaround (mitigated connection pool accumulation; ECS
reduces per-event WP→GitHub API connections by ~99%, eliminating the root cause)
Update docs/ops/runbooks/ci-woodpecker.md: retire Failure mode B-2 mitigations, add
ECS restart and health check runbook entries
Lower WOODPECKER_FORGE_TIMEOUT to 30s in SSM + restart WP
Rollback at any phase: Remove WOODPECKER_CONFIG_SERVICE_ENDPOINT from WP
docker-compose env, restart WP container. WP immediately reverts to native forge fetch.
5-minute operation. The ECS can remain running with no effect on WP.
Migrations
No database schema changes. No data migrations. Changes are:
- New docker-compose service entry in /opt/woodpecker/docker-compose.yml (ECS container).
- New SSM parameters under /ci/config-svc/ (GitHub App credentials).
- One new WP environment variable (WOODPECKER_CONFIG_SERVICE_ENDPOINT) in docker-compose.
- New GitHub App raxx-ci-config (operator browser action).
All changes are reversible. Rollback is described above.
Security considerations
- No stored credentials.
wp-config-svcholds no user auth tokens, no agent secrets, no deploy keys. It caches pipeline YAML (committed to the public repo — not sensitive). - GitHub App private key: PEM stored in SSM SecureString. Never written to disk or logs. Rotatable by generating a new App key, updating SSM, and restarting the ECS container. No WP restart required.
- Installation token TTL: 1 hour, held in memory only. Breach window: at most 1 hour;
token scope:
contents:readon one repo only. - Ed25519 signature verification: Rejects any POST not signed by the WP server key. Defense in depth — the service is localhost-only, but this prevents injection from any other local process.
- Config cache is non-sensitive:
.woodpecker/*.yamlpipeline definitions are committed to a semi-public repo. No redaction needed in logs or cache entries. - CloudWatch logs: 90-day retention, ops@ access only. Token values logged as REDACTED.
- Breach response: GitHub App key leaked → revoke in App settings + generate new key → update SSM → restart ECS. No customer data at risk (read-only contents scope).
Security / GDPR checklist
- PII collected: None. Pipeline config files contain no customer PII.
- Retention period: In-memory cache only; cleared on restart. CloudWatch logs 90 days.
- Deletion on DSR: N/A — no personal data stored or processed.
- Audit trail: Every request logged: timestamp, repo, commit SHA, cache hit/miss, GitHub API call count, response HTTP status, latency. CloudWatch, 90-day retention.
- Stored credentials: GitHub App private key in SSM SecureString
/ci/config-svc/github-app-private-key. Never on disk or in code. In-memory installation tokens only (1hr TTL, read-only scope). - Breach notification: App key leak → revoke + rotate (<15 min). No customer PII at risk. ops@raxx.app notified within 1 hour.
- Secrets location + rotation: All under SSM
/ci/config-svc/. Rotatable without WP restart. Key rotation does not require a maintenance window. - Kill-switch: Remove
WOODPECKER_CONFIG_SERVICE_ENDPOINTfrom WP env → restart WP container → native forge fetch resumes. 5 minutes. Can also stop ECS container independently (WP falls back immediately on next event after ECS non-200).
Language choice rationale
Service: wp-config-svc
Language tier: Tier 2 — Python
Rationale: No Tier 1 criterion applies. The service is not on a latency-critical auth
hot path (p99 target is ~300ms for cache miss, not sub-5ms). It handles no customer PII,
no payment credentials, no audit hash chain, and no cryptographic key material beyond
verifying a standard JWT. Throughput ceiling is ~200 cold events/hr — no Tier 1 volume
threshold. Per docs/architecture/language-tier-policy.md §1, Tier 2 (Python) is the
correct default. aiohttp provides sufficient parallelism for the blob fetch pattern.
API contract portability (Tier 2): The single endpoint POST /config follows the
Woodpecker external config service protocol (stable, Woodpecker-documented JSON schema).
This protocol is language-agnostic; a future Rust or Go rewrite would implement the same
HTTP interface with zero redesign. No portability risk.
Alternatives considered
WP native forge fetch with tuned timeouts (current mitigation)
WOODPECKER_FORGE_TIMEOUT=120s + weekly restart. Addresses symptoms (timeout), not root
cause (O(N) sequential API calls). O(N) cost grows with pipeline count. This stays as
the fallback path; not as the primary path.
Tarball endpoint (GET /repos/{owner}/{repo}/tarball/{sha})
One API call replaces 80. Rejected: downloads the entire repo (~5–15 MB) to retrieve ~400 KB of pipeline files. Wastes bandwidth; decompression adds latency. Git Trees + parallel blobs is marginally more complex but significantly more efficient.
Lambda behind the ALB as the config service endpoint
Serverless, auto-scales, no idle cost. Rejected: Lambda cold start adds 100–500ms; adds a cross-VPC network hop; requires ALB listener rule changes and a new IAM trust policy. The co-located process has equivalent reliability (WP falls back on failure regardless of service location) with lower operational complexity.
Fork or patch Woodpecker to cache internally
WP's forge client in server/forge/github/github.go could be patched to add a SHA
cache internally. Rejected: couples cache lifecycle to the WP process (same restart
behavior); diverges from upstream; no advantage over the external config service which
is exactly what Woodpecker provides for this use case. Staying on the upstream-supported
path reduces maintenance burden.
Open questions
-
GitHub App approval: GitHub may require App review for new App registrations under certain org policies. If review blocks timely provisioning, fall back to the WP OAuth App credentials for Phase 0 shadow mode and upgrade before Phase 1.
sre-agentto confirm at Card A provisioning time. -
ALB bypass for
/api/signature/public-key: The WP public key endpoint athttps://ci.moosequest.net/api/signature/public-keyis an/api/*path, which is covered by the existing ALB priority-25 rule (bypasses Google OIDC, forwards directly to WP). Confirm this is accessible with a WP admin Bearer token only — no OIDC required.sre-agentto verify before Card B implementation. -
WP admin token for public key fetch: The ECS startup needs to fetch the WP Ed25519 public key. Confirm whether this endpoint requires Bearer auth or is unauthenticated. If auth required, add
/ci/config-svc/wp-admin-tokento SSM parameter set in Card A.
Consequences
Positive
- Pipeline config fetch drops from ~8,000ms serial to ~300ms (cache miss) or ~1ms (hit).
- O(N) sequential API calls replaced by O(1) cache lookup on repeated events.
- Weekly restart workaround retired after Phase 1 soak.
- GitHub API rate limit budget increases from 5,000/hr to 15,000/hr with GitHub App.
WOODPECKER_FORGE_TIMEOUTcan be lowered to 30s — tighter failure detection loop.- Failure mode B-2 (connection pool accumulation) structurally eliminated: WP server makes nearly zero per-event GitHub API connections after ECS handles config fetch.
Negative / risks
- New process to operate:
wp-config-svchas its own health check, restart sequence, and log stream. Adds operational surface. - ECS startup failure blocks shadow mode but does not break CI (WP falls back to forge).
- GitHub App provisioning is an operator browser action (GitHub UI — cannot be automated).
- In-process cache grows with distinct SHAs. Bounded at ~40 MB for 100 SHAs; apply LRU cap at 500 entries if memory pressure appears.
Neutral
- Weekly server restart workaround remains in place until Phase 1 is validated.
- ECS is not involved in any customer-facing security boundary.
- The WP native forge fetch path (Failure mode B-2 mitigations) remains documented in
ci-woodpecker.mduntil Phase 2 retirement.
Revisit when
- Pipeline file count grows beyond 200 (parallel blob fetch latency remains constant; review in-process cache memory footprint at that point).
- WP releases v4+ with a changed config-service protocol.
- GitHub App rate limits change or the installation is elevated to org-level.
- EC2 host is replaced (re-provision ECS container; SSM credentials unchanged).