This is a small Astro SSR example that turns a website feedback form into GitHub Issues through a GitHub App installation token. It is configured for Cloudflare Workers.
Live demo: https://astro-github-support-form.design4pro.workers.dev
It is intentionally simple:
- one Astro page with a native HTML form
- one API route at
POST /api/feedback - server-side GitHub App authentication through Octokit
- Cloudflare Turnstile verification before issue creation
- no client-side framework
- no secret committed to the repository
Create a GitHub App with these settings:
- App name:
D4P Support Form Demo - Homepage URL: this repository URL
- Webhooks: inactive
- Repository permissions:
- Metadata: read
- Issues: read and write
- Installation: selected repositories only, then choose this repository
Generate a private key in the app settings and copy the app ID.
bun install
cp .dev.vars.example .dev.varsFill .dev.vars for local Cloudflare Workers development:
GITHUB_APP_ID=1234567
GITHUB_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AAGITHUB_OWNER, GITHUB_REPO, and the production TURNSTILE_SITE_KEY are defined in wrangler.jsonc because they are not secrets. The example .dev.vars values use Cloudflare's dummy Turnstile keys for local development.
GitHub downloads GitHub App private keys as PKCS#1 PEM files. Cloudflare Workers needs a PKCS#8 PEM for this dependency stack. Convert the downloaded key before putting it in .dev.vars or Cloudflare secrets:
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt \
-in github-app.private-key.pem \
-out github-app.private-key.pkcs8.pemThen run:
bun run devOpen http://localhost:4321, submit the form, and check the Issues tab in this repository.
The demo uses @astrojs/cloudflare and wrangler.jsonc.
Create a production Turnstile widget for your Workers domain, then replace TURNSTILE_SITE_KEY in wrangler.jsonc.
Set production secrets in Cloudflare:
CLOUDFLARE_ACCOUNT_ID=your-account-id bun x wrangler secret put GITHUB_APP_ID
CLOUDFLARE_ACCOUNT_ID=your-account-id bun x wrangler secret put GITHUB_PRIVATE_KEY
CLOUDFLARE_ACCOUNT_ID=your-account-id bun x wrangler secret put TURNSTILE_SECRET_KEYGITHUB_OWNER, GITHUB_REPO, and TURNSTILE_SITE_KEY are public variables in wrangler.jsonc.
Deploy:
CLOUDFLARE_ACCOUNT_ID=your-account-id bun run deployPOST /api/feedback accepts JSON:
{
"type": "bug",
"title": "Calendar export fails",
"message": "I clicked Export and the request returned a 500 response.",
"email": "reader@example.com",
"environment": "Safari 18, macOS 15",
"consent": true,
"turnstileToken": "token-from-cf-turnstile-response",
"company": ""
}Successful response:
{
"ok": true,
"referenceId": "SUP-ABC12345"
}Validation error response:
{
"ok": false,
"error": "Invalid feedback payload",
"details": ["Message must be between 10 and 4000 characters."],
"fieldErrors": {
"message": ["Message must be between 10 and 4000 characters."]
}
}For a real support system, create issues in a private repository, keep Turnstile enabled, add rate limiting, decide what personal data you store, and document the retention policy in your privacy notice.