GitHub Webhook Setup (Secure)
This guide walks through connecting TF Code to a GitHub repository so that labeling an issue as tfcode_work triggers an agent run. The deployment is designed for highly regulated industries — every layer is hardened, every secret is explicit, and nothing is exposed without authentication.
Architecture
┌─────────────────────────────────────────┐
│ Your VPC / Server │
│ │
GitHub Webhook ─── HTTPS ──▶ ┌──────┐ ┌──────────────────────┐ │
(issues event) │ nginx │───▶│ TF Code Gateway :8644│ │
Signed with HMAC secret │ :443 │ │ ① IP allowlist │ │
│ TLS │ │ ② HMAC validation │ │
└──────┘ │ ③ Event + filter │ │
│ ④ Prompt render │ │
│ ⑤ Session create │ │
│ ⑥ Agent dispatch │ │
└──────────┬───────────┘ │
│ │
┌──────────▼───────────┐ │
│ Agent (build) │ │
│ - Reads code │ │
│ - Edits files │ │
│ - Runs tests │ │
│ - Posts PR comment │ │
│ via gh CLI │ │
└────────────────────────┘ │
└─────────────────────────────────────────┘
Defense-in-depth layers:
- TLS termination at nginx (no plaintext on the wire)
- IP allowlist — only GitHub's webhook sender IPs can reach the gateway
- HMAC-SHA256 signature validation — payload integrity and authenticity
- Rate limiting — 30 requests per minute per route
- Idempotency cache — duplicate deliveries are silently dropped
- Payload filter — only
labeledevents with thetfcode_worklabel trigger the agent - Agent permissions — sessions deny interactive prompts (
question,plan_enter,plan_exit) - Body size limit — 1MB max payload
Prerequisites
- A server inside your VPC with:
- Bun installed
- TF Code installed (
curl -fsSL https://tfcode.sh | bash) - Your repository cloned at a known path (e.g.
/opt/projects/my-repo) - GitHub CLI (
gh) installed and authenticated (gh auth login)- The authenticated user must have write access to the repository (to post comments and manage labels)
- nginx (or caddy/traefik) for TLS termination
- A TLS certificate (Let's Encrypt, or your organization's CA)
Step 1: Clone the Repository
sudo mkdir -p /opt/projects
sudo chown $USER:$USER /opt/projects
cd /opt/projects
git clone git@github.com:your-org/your-repo.git
cd your-repo
The repository must stay cloned at this path — the agent works in this directory.
Step 2: Authenticate the GitHub CLI
The agent uses gh CLI to interact with GitHub (post comments, manage labels). Authenticate non-interactively in production:
echo "ghp_your_personal_access_token" | gh auth login --with-token
Use a fine-grained personal access token with:
- Repository access: only
your-org/your-repo - Permissions:
Issues(write),Pull requests(write),Metadata(read)
Verify:
gh auth status
gh issue list --repo your-org/your-repo --limit 1
Step 3: Create the TF Code Config
Create /opt/projects/your-repo/tfcode.json:
{
"$schema": "https://opencode.ai/config.json",
"builtin": {
"gateway": { "enabled": true }
},
"gateway": {
"port": 8644,
"host": "127.0.0.1",
"secret": "REPLACE_WITH_GENERATED_SECRET",
"allowed_ips": [
"__VG_IPV4_14082defa6b8__/24",
"__VG_IPV4_e6aece9dd929__/24",
"__VG_IPV4_1960f45daec6__/24",
"__VG_IPV4_36c6a1e98063__/24",
"__VG_IPV4_5ee7552fdd3a__/24"
],
"trusted_proxy": "127.0.0.1",
"rate_limit": 30,
"max_body_bytes": 1048576,
"routes": {
"github-issue": {
"events": ["issues"],
"secret": "REPLACE_WITH_DIFFERENT_ROUTE_SECRET",
"filter": {
"action": "labeled",
"label.name": "tfcode_work"
},
"prompt": "You received a webhook event from GitHub.\n\nEvent: issues (action: {action})\nRepository: {repository.full_name}\nIssue #{issue.number}: {issue.title}\nURL: {issue.html_url}\nTriggered by label: {label.name}\nCreated by: {sender.login}\n\nIssue description:\n{issue.body}\n\nInstructions:\n- Work in the current repository to resolve this issue\n- Read the relevant source files, make code changes, and run tests\n- When done, post a comment on the issue summarizing your changes using: gh issue comment {issue.number} --repo {repository.full_name} --body \"<summary>\"\n- Remove the 'tfcode_work' label and add 'tfcode_done' using:\n gh issue edit {issue.number} --repo {repository.full_name} --remove-label tfcode_work --add-label tfcode_done\n- If you cannot resolve the issue, post a comment explaining what's blocking you\n\nFull webhook payload:\n{__raw__}"
}
}
},
"permission": {
"question": "deny",
"plan_enter": "deny",
"plan_exit": "deny"
}
}
Generate the secrets
# Global HMAC secret (fallback if a route doesn't specify its own)
openssl rand -hex 32
# Per-route secret (used in the GitHub webhook config)
openssl rand -hex 32
Replace REPLACE_WITH_GENERATED_SECRET and REPLACE_WITH_DIFFERENT_ROUTE_SECRET with the generated values.
Get GitHub's webhook IP ranges
GitHub publishes their webhook sender IPs at:
curl -s https://api.github.com/meta | jq '.hooks[]'
Output (example — verify against the live API):
__VG_IPV4_14082defa6b8__/24
__VG_IPV4_e6aece9dd929__/24
__VG_IPV4_1960f45daec6__/24
__VG_IPV4_36c6a1e98063__/24
__VG_IPV4_5ee7552fdd3a__/24
Put these in the allowed_ips array. The gateway will reject any request from an IP outside these ranges — even if the HMAC signature is valid.
Why trusted_proxy matters
Since nginx terminates TLS and forwards to the gateway, the gateway sees nginx's IP (127.0.0.1), not GitHub's real IP. Setting trusted_proxy to 127.0.0.1 tells the gateway to trust the X-Forwarded-For header only when the request comes from nginx. An attacker cannot spoof this header from outside — the gateway ignores X-Forwarded-For unless the source IP matches trusted_proxy.
Permission config
The "permission" block denies question, plan_enter, and plan_exit globally. Since the agent runs autonomously (no human to answer interactive prompts), these are auto-denied. The agent can still allow everything else (bash, read, edit, write, grep, etc.) by default.
Step 4: Configure nginx for TLS Termination
Create /etc/nginx/sites-available/tfcode-gateway:
server {
listen 443 ssl http2;
server_name tfcode-gateway.your-domain.com;
# TLS — use your org's CA or Let's Encrypt
ssl_certificate /etc/ssl/certs/tfcode-gateway.crt;
ssl_certificate_key /etc/ssl/private/tfcode-gateway.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Only forward /webhooks/ and /health
location /webhooks/ {
proxy_pass http://127.0.0.1:8644;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Body size limit (matches gateway config)
client_max_body_size 1m;
# Timeout for sync mode (increase if agent runs are long)
proxy_read_timeout 300s;
}
location /health {
proxy_pass http://127.0.0.1:8644;
proxy_set_header Host $host;
}
# Block everything else
location / {
return 404;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name tfcode-gateway.your-domain.com;
return 301 https://$server_name$request_uri;
}
Enable and test:
sudo ln -s /etc/nginx/sites-available/tfcode-gateway /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Firewall rules
Restrict port 443 to only GitHub's webhook IPs at the network level (belt-and-suspenders with the application-level IP allowlist):
# Example with ufw (adjust CIDRs from api.github.com/meta)
for cidr in $(curl -s https://api.github.com/meta | jq -r '.hooks[]'); do
sudo ufw allow from "$cidr" to any port 443 proto tcp
done
sudo ufw deny 443
Step 5: Start the TF Code Server
Create a systemd service at /etc/systemd/system/tfcode.service:
[Unit]
Description=TF Code Server
After=network.target
[Service]
Type=simple
User=tfopt
WorkingDirectory=/opt/projects/your-repo
Environment=OPENCODE_SERVER_PASSWORD=REPLACE_WITH_SERVER_PASSWORD
ExecStart=/home/tfopt/.bun/bin/tfcode serve
Restart=always
RestartSec=5
# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/opt/projects/your-repo /home/tfopt/.local/share/tfcode
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now tfcode
sudo systemctl status tfcode
Verify the gateway is running:
curl http://localhost:8644/health
# {"status":"ok","platform":"webhook"}
Check the logs:
journalctl -u tfcode -f --since "1 min ago"
# Look for: "gateway webhook server listening" with port, host, and route count
Step 6: Configure the GitHub Webhook
- Go to your repository → Settings → Webhooks → Add webhook
- Payload URL:
https://tfcode-gateway.your-domain.com/webhooks/github-issue - Content type:
application/json - Secret: paste the same per-route secret you generated in Step 3
- Which events would you like to trigger this webhook?: select Let me select individual events → check Issues
- Active: checked
- Click Add webhook
GitHub will send a ping event immediately. Check the Recent Deliveries tab — you should see the ping with a green checkmark. The gateway will return {status: "filtered", reason: "event_type"} for the ping (it's not an issues event), which GitHub shows as a 200 OK (green).
Step 7: Test It
Test with log mode first
Temporarily add "log": true to the route config to verify the pipeline without spending LLM tokens:
"github-issue": {
"events": ["issues"],
"secret": "REPLACE_WITH_DIFFERENT_ROUTE_SECRET",
"filter": { "action": "labeled", "label.name": "tfcode_work" },
"log": true,
"prompt": "..."
}
Restart tfcode, then create a test issue and add the tfcode_work label.
Check the logs:
journalctl -u tfcode --since "1 min ago" | grep "log mode"
# Should show: "log mode — skipping agent" with the rendered prompt
Remove "log": true once verified and restart.
Full test
- Create a new issue in your GitHub repository:
- Title: "Fix typo in README"
- Body: "The README says 'tfcoed' instead of 'tfcode' in the installation section."
- Add the label
tfcode_work - Check the logs:
journalctl -u tfcode -f
You should see:
INFO gateway webhook server listening port=8644 host=127.0.0.1 routes=1
INFO created session for webhook sessionID=sess_xxx route=github-issue source=github repo=your-org/your-repo
INFO dispatched async webhook to agent sessionID=sess_xxx route=github-issue
- The agent will:
- Read
README.md - Find and fix the typo
- Run any tests
- Post a comment on the GitHub issue:
- Read
gh issue comment <number> --repo your-org/your-repo --body "Fixed the typo in README.md..."
- Remove the
tfcode_worklabel and addtfcode_done:
gh issue edit <number> --repo your-org/your-repo --remove-label tfcode_work --add-label tfcode_done
- Check the issue on GitHub — you should see the comment and the label change.
Step 8: Create the Labels in GitHub
Create two labels in your repository if they don't exist:
gh label create tfcode_work --description "Issue assigned to TF Code for automated processing" --color D93F0B
gh label create tfcode_done --description "Issue completed by TF Code" --color 0E8A16
Security Hardening Checklist
| Layer | What | Where |
|---|---|---|
| TLS | nginx terminates TLS, gateway speaks HTTP internally | nginx config |
| Network firewall | Port 443 restricted to GitHub webhook IPs | ufw / security group / iptables |
| Application IP allowlist | gateway.allowed_ips rejects non-GitHub IPs | tfcode.json |
| Trusted proxy | trusted_proxy prevents X-Forwarded-For spoofing | tfcode.json |
| HMAC-SHA256 | Every request signed, validated with timing-safe comparison | route.secret |
| Per-route secret | Each route has its own secret (not just global) | route.secret |
| Rate limiting | 30 req/min per route prevents flooding | gateway.rate_limit |
| Idempotency | Duplicate deliveries silently dropped (1hr TTL) | automatic |
| Body size limit | 1MB max payload | gateway.max_body_bytes |
| Payload filter | Only labeled + tfcode_work triggers agent | route.filter |
| Agent permissions | Interactive prompts denied | permission in config |
| systemd hardening | NoNewPrivileges, ProtectSystem, ProtectHome | systemd unit |
| Fine-grained PAT | GitHub CLI token scoped to single repo, minimal permissions | gh auth login |
| Server password | TF Code API protected with basic auth | OPENCODE_SERVER_PASSWORD |
Troubleshooting
Webhook not arriving
# Check if the gateway is running
curl http://localhost:8644/health
# Check nginx is forwarding
sudo nginx -t
sudo tail /var/log/nginx/error.log
# Check firewall
sudo ufw status
# Check GitHub's recent deliveries
# Repository → Settings → Webhooks → [your webhook] → Recent Deliverables
Signature validation failing
# Verify the secret in tfcode.json matches the secret in GitHub webhook settings
# They must be exactly the same string
# Check the raw signature header
# GitHub sends: X-Hub-Signature-256: sha256=<hex>
# The gateway expects exactly this format
# Test locally with the same secret
echo -n '{"action":"labeled"}' | openssl dgst -sha256 -hmac "your-secret"
IP allowlist blocking legitimate requests
# Get current GitHub webhook IPs
curl -s https://api.github.com/meta | jq '.hooks'
# Check what IP the request is coming from
# Look in nginx access log:
sudo tail /var/log/nginx/access.log
# If behind a load balancer, the IP might differ — check X-Forwarded-For
# Ensure trusted_proxy is set correctly
Agent not posting comments
# Verify gh CLI is authenticated
gh auth status
# Verify the token has write access
gh issue comment 1 --repo your-org/your-repo --body "test comment"
# Check tfcode logs for errors
journalctl -u tfcode --since "5 min ago" | grep -i error
Testing the webhook locally
# Send a test request with curl
SECRET="your-route-secret"
BODY='{"action":"labeled","issue":{"number":42,"title":"Test","body":"Test body","html_url":"https://github.com/your-org/your-repo/issues/42"},"label":{"name":"tfcode_work"},"repository":{"full_name":"your-org/your-repo"},"sender":{"login":"testuser"}}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
curl -X POST http://localhost:8644/webhooks/github-issue \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: issues" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-H "X-GitHub-Delivery: $(uuidgen)" \
-d "$BODY"
Expected response (non-log mode):
{ "status": "accepted", "route": "github-issue", "session": "sess_abc123" }
Expected response (log mode):
{ "status": "logged", "route": "github-issue" }