A GitHub Marketplace app that automatically syncs GitHub Pages custom domains with Cloudflare DNS. When you configure a custom domain for GitHub Pages, this app creates the corresponding CNAME record in Cloudflare—no manual DNS updates required.
- Multi-site Management: Automatically manage DNS for dozens of GitHub Pages sites
- Team Deployments: Enable instant custom domain setup for documentation sites
- CI/CD Integration: Trigger DNS updates as part of automated deployments
Before using Pages Proxy, you must verify your domain with GitHub:
- Go to your GitHub organization settings → Verified domains
- Add your domain (e.g.,
example.com) and follow the verification steps - GitHub will provide a TXT record to add to your DNS
- Once verified, you can use any subdomain (e.g.,
docs.example.com,blog.example.com) for GitHub Pages
📖 GitHub's Domain Verification Guide
When you configure a custom domain in GitHub Pages, Pages Proxy automatically:
- Receives webhook - GitHub sends
page_buildorpagesevent - Fetches custom domain - Reads the CNAME from your repository or Pages API
- Derives origin hostname - Uses
<org>.github.ioas the DNS target (e.g.,my-org.github.io) - Creates CNAME record - Adds DNS record in Cloudflare with these settings:
- Name: Your custom domain (e.g.,
docs.example.com) - Target:
<org>.github.io(e.g.,my-org.github.io) - Proxied:
false(DNS-only mode) - TTL:
60seconds (1 minute for fast propagation)
- Name: Your custom domain (e.g.,
Why DNS-only mode? GitHub Pages uses Let's Encrypt to automatically provision SSL certificates. This requires GitHub to verify domain ownership using the ACME HTTP-01 challenge. If Cloudflare proxy is enabled, the challenge fails because Cloudflare intercepts the request.
Default workflow:
- Pages Proxy creates CNAME in DNS-only mode (
proxied: false) - GitHub Pages detects the DNS record and initiates Let's Encrypt verification
- After 5-15 minutes, GitHub provisions the SSL certificate
- "Enforce HTTPS" checkbox becomes available in GitHub Pages settings
After GitHub's SSL certificate is active, you can optionally enable Cloudflare proxy for:
- Edge caching and performance optimization
- DDoS protection and WAF
- IP masking (hides
github.ioorigin)
To enable proxy:
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/<zone_id>/dns_records/<record_id>" \
-H "Authorization: Bearer <api_token>" \
-H "Content-Type: application/json" \
--data '{"proxied":true}'Find <record_id> in Cloudflare dashboard → DNS → Records.
If you change your custom domain in GitHub Pages:
- Disable Cloudflare proxy first (if enabled) - Change
proxied: true→false - Update custom domain in GitHub Pages - This triggers webhook to update DNS
- Wait for new SSL certificate - GitHub provisions new cert for updated domain (5-15 minutes)
- Re-enable proxy (optional) - After HTTPS is enforced
With TTL set to 60 seconds, DNS changes propagate quickly:
- Cloudflare edge: Instant
- ISP resolvers: 1-5 minutes
- Global propagation: 5-15 minutes
You can verify DNS propagation with:
dig docs.example.com CNAME
# Should show: docs.example.com. 60 IN CNAME my-org.github.io.You have two options: use the hosted Marketplace app (recommended) or self-host from source.
The easiest way to use Pages Proxy is to install it directly from the GitHub Marketplace. The app is hosted and maintained, so you don't need to run any infrastructure.
- Go to GitHub Marketplace - Pages Proxy
- Click "Install it for free"
- Select the organization or account where you want to use it
- Grant access to the repositories that use GitHub Pages
- After installation, GitHub will redirect you to the setup page
You'll be redirected to a secure setup page where you enter your Cloudflare credentials:
-
Cloudflare Zone ID - Find this in your Cloudflare dashboard → Select your domain → Overview (right sidebar)
-
Cloudflare API Token - Create a token with Zone → DNS → Edit permissions
- How to create API token
- Important: Use minimum required permissions for security
-
Cloudflare Email (Optional) - Only needed for legacy Global API Key authentication (not recommended)
-
Click "Save Configuration"
Your credentials are encrypted with AES-256-GCM before storage. You can update them anytime by revisiting the setup page.
Security Note: This service stores your Cloudflare credentials encrypted on our servers. By proceeding, you agree to our Terms of Service and Privacy Policy. The service is provided AS-IS with no warranty or SLA.
Once configured:
- Go to your repository → Settings → Pages
- Set a custom domain (e.g.,
docs.example.com) - The app automatically creates/updates the CNAME record in Cloudflare
- Wait 1-5 minutes for DNS propagation
- Your Pages site is live at your custom domain!
To update your Cloudflare credentials later: Reinstall the app or contact support for the setup URL.
To remove DNS records: Simply remove the custom domain in GitHub Pages settings, and the app automatically deletes the Cloudflare record.
If you prefer to run your own instance (for example, to customize behavior or run in a private environment), follow these instructions.
- Node.js 20+
- Docker (optional, for containerized deployment)
- Kubernetes/OpenShift cluster (optional, for production deployment)
- GitHub App credentials (App ID, Installation ID, Private Key, Webhook Secret)
- Cloudflare API credentials
- Clone the repository:
git clone https://github.com/shakerg/pages-proxy.git
cd pages-proxy-
Create
.envfile with your credentials (see Environment Variables below) -
Install dependencies and start:
npm install
npm startThe server listens on PORT (default 3000).
If self-hosting, create a .env file in the project root:
PORT=3000
DB_PATH=pages.db
# Encryption key for storing Cloudflare credentials (generate with: openssl rand -base64 48)
ENCRYPTION_KEY=<your_secure_64_character_encryption_key>
# GitHub App (get these from your GitHub App settings)
GITHUB_APP_ID=<your_app_id>
GITHUB_INSTALLATION_ID=<installation_id>
GITHUB_WEBHOOK_SECRET=<webhook_secret>
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n"
# Cloudflare (for self-hosted global config, or leave blank if using per-installation setup)
# Recommended: use `CLOUDFLARE_API_TOKEN` with minimal DNS edit permissions
CLOUDFLARE_ZONE_ID=<zone_id>
CLOUDFLARE_API_TOKEN=<api_token_with_dns_edit_permissions>
CLOUDFLARE_EMAIL=<account_email>Notes:
ENCRYPTION_KEYis required for encrypting stored Cloudflare credentials (min 32 characters)- For containers/Kubernetes, mount the private key as a file and use
PRIVATE_KEY_PATHinstead ofGITHUB_APP_PRIVATE_KEY - The app dynamically generates installation tokens—do not hardcode
GITHUB_APP_TOKEN - Keep secrets secure and never commit them to version control
- If using per-installation setup UI (like marketplace), Cloudflare vars can be omitted
To self-host, you need to create your own GitHub App (not use the Marketplace app):
- Go to GitHub → Settings → Developer settings → GitHub Apps → New GitHub App
- Configure:
- App name:
Pages Proxy (Self-Hosted)or similar - Homepage URL: Your deployment URL (e.g.,
https://pages-proxy.yourdomain.com) - Setup URL:
https://your-host.com/setup?installation_id={installation_id}(for per-user configuration) - Webhook URL:
https://your-host.com/webhook(must be HTTPS and publicly accessible) - Webhook Secret: Generate with
openssl rand -base64 32
- App name:
- Set Permissions:
- Pages: Read & Write
- Contents: Read
- Metadata: Read
- Subscribe to Events:
- Repository
- Page build
- Pages
- Create the app, then:
- Download the Private Key (PEM file)
- Note the App ID
- Install the app to your org/account and note the Installation ID from the URL
Build and run with Docker:
# Build for linux/amd64 (recommended for most cloud platforms)
docker build --platform=linux/amd64 -t pages-proxy:latest .
# Run with environment variables
docker run --rm -p 3000:3000 \
-e GITHUB_APP_ID="$GITHUB_APP_ID" \
-e GITHUB_INSTALLATION_ID="$GITHUB_INSTALLATION_ID" \
-e GITHUB_WEBHOOK_SECRET="$GITHUB_WEBHOOK_SECRET" \
-e GITHUB_APP_PRIVATE_KEY="$GITHUB_APP_PRIVATE_KEY" \
-e CLOUDFLARE_ZONE_ID="$CLOUDFLARE_ZONE_ID" \
-e CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN" \
pages-proxy:latestMounting private key as file (recommended for production):
docker run --rm -p 3000:3000 \
-v /path/to/private-key.pem:/app/private-key.pem:ro \
-e PRIVATE_KEY_PATH="/app/private-key.pem" \
-e GITHUB_APP_ID="$GITHUB_APP_ID" \
-e GITHUB_INSTALLATION_ID="$GITHUB_INSTALLATION_ID" \
-e GITHUB_WEBHOOK_SECRET="$GITHUB_WEBHOOK_SECRET" \
-e CLOUDFLARE_ZONE_ID="$CLOUDFLARE_ZONE_ID" \
-e CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN" \
pages-proxy:latestExample manifests are in manifests/:
- Create secrets with your credentials:
kubectl create secret generic pages-proxy-secrets \
--from-literal=GITHUB_APP_ID="<app-id>" \
--from-literal=GITHUB_INSTALLATION_ID="<installation-id>" \
--from-literal=GITHUB_WEBHOOK_SECRET="<webhook-secret>" \
--from-literal=ENCRYPTION_KEY="<64-char-encryption-key>" \
--from-file=GITHUB_APP_PRIVATE_KEY=./private-key.pem \
--from-literal=CLOUDFLARE_ZONE_ID="<zone-id>" \
--from-literal=CLOUDFLARE_API_TOKEN="<api-token>"- Deploy:
kubectl apply -f manifests/pages-deployment.yml-
Expose with Ingress/Route to make the webhook endpoint publicly accessible at
https://your-domain.com/webhook -
Update GitHub App webhook URL to point to your public endpoint
Generate a valid webhook signature and test:
PAYLOAD='{"zen":"testing","hook_id":123}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$GITHUB_WEBHOOK_SECRET" | awk '{print "sha256="$2}')
curl -X POST https://your-domain.com/webhook \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: ping" \
-H "X-Hub-Signature-256: $SIGNATURE" \
-d "$PAYLOAD" -iExpected responses:
- 200 OK: Signature valid, webhook processed
- 401 Unauthorized: Invalid signature (anti-spoofing working correctly)
Test endpoints for database operations without Cloudflare API calls:
# Store a custom domain
curl -X POST http://localhost:3000/test-store \
-H 'Content-Type: application/json' \
-d '{"repoName":"org/repo","pagesUrl":"https://org.github.io/repo","customDomain":"example.com"}'
# Remove a custom domain
curl -X POST http://localhost:3000/test-remove \
-H 'Content-Type: application/json' \
-d '{"repoName":"org/repo"}'- POST
/webhook- Receives GitHub webhook events (requires valid signature)
- POST
/test-store- Store Pages URL and custom domain in database - POST
/test-remove- Remove Pages URL from database
- POST
/update-cname- Manually create/update Cloudflare CNAME record - POST
/refresh-token- Manually refresh GitHub App installation token
- Webhook Handler: Express server with signature verification (anti-spoofing)
- GitHub App Auth: JWT-based authentication with automatic token refresh
- Cloudflare Integration: REST API calls for DNS record management
- SQLite Database: Persistent storage for Pages URLs and custom domains
- Token Manager: Caches installation tokens and refreshes before expiry
- Webhook signature verification (HMAC-SHA256) prevents spoofed requests
- Automatic token rotation (installation tokens refreshed every 50 minutes)
- Private key isolation (mounted as file, never logged)
- HTTPS required for webhook endpoint
- Minimal permissions (Pages: Write, Contents: Read, Metadata: Read)
- Cause: GitHub App private key not loaded correctly
- Fix:
- Verify
GITHUB_APP_PRIVATE_KEYcontains the full PEM (including BEGIN/END lines) - Or mount the PEM file and set
PRIVATE_KEY_PATH - Check for extra quotes or escaped newlines (
\nshould be actual newlines)
- Verify
- Cause: Invalid API token or zone ID
- Fix:
- Verify
CLOUDFLARE_API_TOKENhas DNS Edit permissions - Confirm
CLOUDFLARE_ZONE_IDmatches your domain's zone in Cloudflare dashboard - Check token hasn't expired or been revoked
- Verify
- Cause: Signature mismatch between GitHub and your app
- Fix:
- Ensure
GITHUB_WEBHOOK_SECRETin app settings matches your environment variable - Check reverse proxy/load balancer isn't modifying request body
- Verify webhook is configured to send
application/json(notapplication/x-www-form-urlencoded)
- Ensure
- Cause: Wrong App ID or private key mismatch
- Fix:
- Verify
GITHUB_APP_IDmatches the app ID in GitHub App settings - Ensure private key corresponds to the app (regenerate if needed)
- Check JWT expiry (app generates 10-minute JWTs)
- Verify
- Cause: Installation not found or app not installed
- Fix:
- Confirm app is installed on the org/account
- Verify
GITHUB_INSTALLATION_IDfrom the installation URL (e.g.,https://github.com/settings/installations/12345678) - Reinstall the app if necessary
- Cause: Webhook URL unreachable or incorrectly configured
- Fix:
- Ensure webhook URL is publicly accessible via HTTPS
- Check GitHub App settings → Recent Deliveries for error messages
- Test endpoint with
curlto verify it responds - Verify firewall/network allows inbound traffic
Enable verbose logging by checking pod/container logs:
# Kubernetes/OpenShift
kubectl logs -l app=pages-proxy -c app --tail=100
# Docker
docker logs <container-id>Look for:
- Token generation messages
- Webhook signature verification logs
- Cloudflare API responses
- Database operations
The app uses SQLite with the following tables:
repo_name(TEXT, PRIMARY KEY) - Full repository name (org/repo)pages_url(TEXT) - GitHub Pages URLcustom_domain(TEXT, nullable) - Custom domain configured
domain(TEXT, PRIMARY KEY) - Custom domainrecord_id(TEXT) - Cloudflare DNS record IDrepo_name(TEXT) - Associated repository
id(INTEGER, PRIMARY KEY) - Row IDtoken(TEXT) - GitHub App installation tokenexpires_at(TEXT) - ISO timestamp when token expires
installation_id(INTEGER, PRIMARY KEY) - GitHub App installation IDcloudflare_zone_id(TEXT) - Per-installation Cloudflare zone IDcloudflare_api_token(TEXT) - Encrypted API token (AES-256-GCM)cloudflare_email(TEXT, nullable) - Cloudflare email for legacy authcreated_at(TEXT) - ISO timestamp of creationupdated_at(TEXT) - ISO timestamp of last update
Contributions are welcome! To contribute:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make your changes and test locally
- Commit with clear messages (
git commit -m 'Add amazing feature') - Push to your fork (
git push origin feature/amazing-feature) - Open a Pull Request
git clone https://github.com/shakerg/pages-proxy.git
cd pages-proxy
npm install
# Create .env with your test credentials
npm startMIT License - see LICENSE file for details.