Skip to main content

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:

  1. TLS termination at nginx (no plaintext on the wire)
  2. IP allowlist — only GitHub's webhook sender IPs can reach the gateway
  3. HMAC-SHA256 signature validation — payload integrity and authenticity
  4. Rate limiting — 30 requests per minute per route
  5. Idempotency cache — duplicate deliveries are silently dropped
  6. Payload filter — only labeled events with the tfcode_work label trigger the agent
  7. Agent permissions — sessions deny interactive prompts (question, plan_enter, plan_exit)
  8. 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

  1. Go to your repository → SettingsWebhooksAdd webhook
  2. Payload URL: https://tfcode-gateway.your-domain.com/webhooks/github-issue
  3. Content type: application/json
  4. Secret: paste the same per-route secret you generated in Step 3
  5. Which events would you like to trigger this webhook?: select Let me select individual events → check Issues
  6. Active: checked
  7. 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

  1. 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."
  2. Add the label tfcode_work
  3. 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
  1. The agent will:
    • Read README.md
    • Find and fix the typo
    • Run any tests
    • Post a comment on the GitHub issue:
gh issue comment <number> --repo your-org/your-repo --body "Fixed the typo in README.md..."
  1. Remove the tfcode_work label and add tfcode_done:
gh issue edit <number> --repo your-org/your-repo --remove-label tfcode_work --add-label tfcode_done
  1. 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

LayerWhatWhere
TLSnginx terminates TLS, gateway speaks HTTP internallynginx config
Network firewallPort 443 restricted to GitHub webhook IPsufw / security group / iptables
Application IP allowlistgateway.allowed_ips rejects non-GitHub IPstfcode.json
Trusted proxytrusted_proxy prevents X-Forwarded-For spoofingtfcode.json
HMAC-SHA256Every request signed, validated with timing-safe comparisonroute.secret
Per-route secretEach route has its own secret (not just global)route.secret
Rate limiting30 req/min per route prevents floodinggateway.rate_limit
IdempotencyDuplicate deliveries silently dropped (1hr TTL)automatic
Body size limit1MB max payloadgateway.max_body_bytes
Payload filterOnly labeled + tfcode_work triggers agentroute.filter
Agent permissionsInteractive prompts deniedpermission in config
systemd hardeningNoNewPrivileges, ProtectSystem, ProtectHomesystemd unit
Fine-grained PATGitHub CLI token scoped to single repo, minimal permissionsgh auth login
Server passwordTF Code API protected with basic authOPENCODE_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" }