Deploying from GitHub to Cloudflare with Wrangler
During a recent project with PowerOutage.us we wanted to use Cloudflare’s Preview URLs to get per-branch (technically per pull request) URLs for our Workers (to be clear we were not using Cloudflare Pages). It turned out to be a little trickier than I anticipated.
The Fundamentals #
The core command we have to work with is versions upload, specifically this:
wrangler versions upload \
--env $ENVIRONMENT \
--tag $VERSION_TAG \
--preview-alias $PREVIEW_ALIAS
This will deploy a Worker that is configured like the Worker in the same environment, e.g. similar variables and bindings, but with a special URL.
$VERSION_TAG- The text that shows up in the Cloudflare deployments dashboard and in API searches.- The maximum length is 25 characters.
- You can get it from the API, but if you use
wrangleryou don’t get the tag on the version list output.
$PREVIEW_ALIAS- Give each upload the same alias URL, e.g. per-PR.- When you use
versions uploadit’ll create a URL that’s per-upload, so it would change after every deploy. - This alias gives you an additional URL that can be something like
$PR_ID-my-app.workers.dev
- When you use
One additional annoyance is that wrangler doesn’t give you the generated URLs in any useful output stream, so we have to use this bash trick to capture the output of Wrangler:
TEMP_FILE=$(mktemp)
wrangler versions upload \
--env $ENVIRONMENT \
--tag $VERSION_TAG \
--preview-alias $PREVIEW_ALIAS \
| tee "${TEMP_FILE}"
WRANGLER_OUTPUT=$(cat "${TEMP_FILE}")
The tee lets us pipe the logs into the temp file while also piping to the GitHub Workflow logger, that way we see logs stream in instead of all showing up at the end if you tried WRANGLER_OUTPUT=$(wrangler ...) instead.
The Details #
This assumes you only have one application in your repo to deploy. If you are running a monorepo with multiple deployable apps, you will need to adapt these things.
Dependencies #
Make sure you have wrangler as a dependency, so that you can do npx wrangler in your scripts.
Workflow #
For preview URLs you probably want it on pull requests:
on:
pull_request:
branches:
- "main" # only PRs into main, for example
You of course need to checkout your repo, install dependencies, and so on:
jobs:
preview:
environment: staging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ">=24.13.0"
- run: npm install
# ... run tests and so on if you want ...
Then we’ll add this step which does the heavy lifting:
jobs:
preview:
steps:
# ... previous steps, then ...
- name: Preview URLs
id: preview_urls
env:
ENVIRONMENT: staging # or whatever
CLOUDFLARE_ACCOUNT_ID: $
CLOUDFLARE_API_TOKEN: $
PULL_REQUEST_ID: $
PULL_REQUEST_NUMBER: $
run: |
VERSION_TAG=pr:${PULL_REQUEST_NUMBER}
PREVIEW_ALIAS=preview-$PULL_REQUEST_ID
# Capture output of Wrangler to a temp file
TEMP_FILE=$(mktemp)
wrangler versions upload --env $ENVIRONMENT --tag $VERSION_TAG --preview-alias $PREVIEW_ALIAS | tee "${TEMP_FILE}"
WRANGLER_OUTPUT=$(cat "${TEMP_FILE}")
# Now we grep for the generated preview URL, which is the per-deploy version
PREVIEW_URL=$(echo "${WRANGLER_OUTPUT}" | grep "^Version Preview URL: " | sed 's/^Version Preview URL: //')
# And the same for the alias, which is the per-PR version
PREVIEW_ALIAS_URL=$(echo "${WRANGLER_OUTPUT}" | grep "^Version Preview Alias URL: " | sed 's/^Version Preview Alias URL: //')
# Then echo them out so we can get at them in next steps
echo "PREVIEW_DEPLOY_URL=${PREVIEW_URL}" >> $GITHUB_OUTPUT
echo "PREVIEW_PR_URL=${PREVIEW_ALIAS_URL}" >> $GITHUB_OUTPUT
Some notes:
id- You’ll use this in your next steps to get at the generated URLs.VERSION_TAG- The result is something likepr:12where12is the 12th PR in your repo- This matches the number in the URL of the PR.
PREVIEW_ALIAS- The result is something likepreview-123456789- This is a per-PR number as well, but it’s globally unique.
- The reason you want this is so someone who stumbles across your preview URL can’t start enumerating
12->13to see your previews.
Technically that’s it, the hard part is done: you’ve deployed your Worker and you’ve got a preview URL.
Add a Comment #
Since this is running for a PR, one thing I like to do is add a comment to the PR with the URL.
And since I always end up having a bunch of back and forth, I like to upsert the comment, so that there’s only the one.
jobs:
preview:
steps:
# ... previous steps, then ...
- name: Add PR Comment
uses: actions/github-script@v8
env:
PREVIEW_DEPLOY_URL: $
PREVIEW_PR_URL: $
with:
script: |
const { PREVIEW_DEPLOY_URL, PREVIEW_PR_URL } = process.env;
const body = [
`🛠️ As of ${context.payload.pull_request?.head?.sha} this PR has the following URLs:`,
`- For this PR: ${PREVIEW_PR_URL}`,
`- For the most recent deploy: ${PREVIEW_DEPLOY_URL}`,
'<!-- workflow-pr-bot -->'
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.type === "Bot" &&
c.body.includes("<!-- workflow-pr-bot -->")
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
Some notes:
- The
steps.preview_urlsis referencing the previous step’sidvalue, make sure your IDs match. - Adding the SHA in the comment helps verify that the bot comment in the PR is from the latest update.
- To upsert I add the HTML comment
<!-- workflow-pr-bot -->so that it’s not visible but is searchable. - Then we just look up comments, search for the bot one and either update or add a new comment (upsert).