Reference architecture for a low cost GitHub App on AWS Lambda. Focus is on how to build it: webhook verification, GitHub App auth, SSM secrets, and synchronous handling. The sample handler (release → cross repo repository_dispatch) is optional; swap the handler for your own workflow.
flowchart TB
subgraph github [GitHub]
App[GitHub App]
Webhook[Webhook POST]
ContentsAPI[Contents API]
DispatchAPI[repository_dispatch API]
Actions[Target repo Actions]
end
subgraph aws [AWS]
FuncURL[Lambda Function URL]
Lambda[Go Lambda bootstrap]
SSM[SSM Parameter Store]
CW[CloudWatch Logs]
end
App --> Webhook
Webhook -->|HTTPS| FuncURL
FuncURL --> Lambda
Lambda -->|init| SSM
Lambda -->|HMAC verify| Lambda
Lambda -->|read app-config.yaml| ContentsAPI
Lambda -->|fan-out| DispatchAPI
DispatchAPI --> Actions
Lambda --> CW
sequenceDiagram
participant GH as GitHub
participant URL as LambdaFunctionURL
participant L as GoHandler
participant SSM as SSM
participant API as GitHubAPI
Note over L,SSM: init once per container
L->>SSM: GetParameter app-id, key, webhook-secret
GH->>URL: POST webhook + x-hub-signature-256
URL->>L: LambdaFunctionURLRequest
L->>L: verify HMAC-SHA256
L->>L: parse payload, determine event type
alt unsupported event
L-->>GH: 200 skip
else supported
L->>API: GetContents app-config.yaml
loop each target
L->>API: repository_dispatch
end
L-->>GH: 200 or 500
end
Webhook security. Function URL is public (authorization_type = "NONE"). Trust comes from HMAC-SHA256 on x-hub-signature-256 (lowercase on Lambda URLs). Bad signature → 400/401.
Secrets. Env vars store SSM paths, not values. init() loads App ID, private key, and webhook secret once per warm container.
GitHub auth. ghinstallation wraps the HTTP client. Installation ID from the webhook scopes API calls to that org.
Config in repo. Rules live in .github/app-config.yaml, fetched via Contents API. Change targets without redeploying Lambda.
Status codes. Unsupported events return 200 (no retry). Processing errors return 500 (GitHub retries). Individual dispatch failures are logged; webhook still returns 200.
Cost choices. Function URL (no API Gateway), Go on provided.al2023 arm64, 256 MB, no VPC, SSM instead of Secrets Manager.
| File | Role |
|---|---|
app/main.go |
Handler and init |
app/webhook.go |
Signature verification |
app/webhook_processor.go |
Event routing |
app/github_auth.go |
GitHub App client |
app/repo_config_loader.go |
Repo config |
app/github_dispatch.go |
Outbound API (sample) |
app/config.go |
SSM loader |
infra/main.tf |
Lambda, IAM, Function URL |
GitHub App with contents (read), administration (read/write), release webhook, and a webhook secret. AWS credentials, Terraform ≥ 1.5, Go 1.24.4.
Store credentials in SSM:
aws ssm put-parameter --name "/dev/github-app/app-id" --value "YOUR_APP_ID" --type "String"
aws ssm put-parameter --name "/dev/github-app-private-key" --value file://your-app.private-key.pem --type "SecureString"
aws ssm put-parameter --name "/dev/github-app-webhook-secret" --value "YOUR_WEBHOOK_SECRET" --type "SecureString"Deploy and point the GitHub App webhook at the Function URL:
make deploySet webhook URL, webhook secret (same as SSM), and enable Release events.
Source repo .github/app-config.yaml:
dispatches:
- event: "release"
targets:
- repo: "target-repo-1"
event_type: "deploy"Target repo workflow:
on:
repository_dispatch:
types: [deploy]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "${{ github.event.client_payload.release.tag_name }}"Targets use the same owner as the source repo (payload.Repository.Owner.Login).
aws logs tail /aws/lambda/serverless-github-app-123456789012-us-east-1 --follow
make testUse the function name from terraform output.