The Challenge: Maintaining on the Road

Back in 2024, my wife and I were preparing to spend several months in south east Asia, mainly motorbiking Vietnam. We had just got married, we both quit our jobs to take a break and booked a one way ticket with no set timeframe for when we wanted to get back home.

There was just one problem…. I really did not want to leave ghcertified - an open source project that I maintain unattended for months.

Star History Chart

I couldn’t bring a laptop. We were going to be motorbiking across Vietnam on a single 125cc Honda Fortune with just two backpacks and could not spare space for it. Also that would kind of defeat the purpose of the trip.

I thought to myself:

What if I can setup some automation to allow me to comfortably keep the project running using only my phone 🤔

So before we left for our journey, that’s exactly what I built. A GitHub Actions workflow triggered straight from my phone.

In this post I’ll take you through the road to the solution, the lessons learned, and what I’d do differently today.

Quick background on ghcertified

Before diving into the solution, let me give you some context on the project itself.

ghcertified.com is an open source project I built that offers free practice tests for all GitHub Certification exams.

Hundreds of questions contributed by the community, thousands of users studying for exams

Screenshot of ghcertified website

I needed every PR to be properly verified before going live. Debugging and fixing issues from my phone simply wasn’t an option.

🙏 Before leaving I also reached out to one of the contributors, Johan Nordberg, who graciously agreed to help with reviewing incoming PRs while I was away. Huge shoutout to him for that! 🙏

The Goal: Visual Verification

So what did I need to comfortably maintain the project from GitHub Mobile?

I already had an existing CI (continuous integration) workflow that ran several checks for any proposed changes - and for most PR’s coming in that was usually enough for me.

However, I’ve had situations where CI checks missed specific issues that broke practice tests. At home, I could quickly revert and fix them.

That was not something I would want to deal with when sipping my daily roadside Bạc Xỉu coffee with only my phone available.

Bac xiu coffee to go on ha giang loop

A month or two before we left for our trip I knew what I had to do:

🎯 GOAL 🎯: I needed a way to request a preview environment of my website straight from my phone. A running website on a separate domain with the proposed changes from any pull request.

This would allow me to visually verify changes before merging, giving me the peace of mind that everything works as expected before the changes go live to my users.

Cloudflare preview deployments

Let’s first understand the stack behind ghcertified and how it is deployed.

The main ghcertified page is hosted on Cloudflare Pages and is updated automatically through Cloudflare’s GitHub App. Every time the master branch is updated, the website on ghcertified.com updates.

Screenshot of cloudflare github app

Cloudflare Pages also offers preview deployments. If you enable that, then every time you open a new pull request on your GitHub repository, Cloudflare Pages will deploy changes from that PR to a preview environment and post the URL to that environment as a pull request comment.

Screenshot of cloudflare comments

At first I thought I’m all set - this is exactly what I needed! Right?

Almost… that approach only works for pull requests that originate from the same repository.

This means it does not work for PRs coming from forks, which is how all external contributions to open source repositories come in.

I need a custom solution…

Building preview deployments with GitHub Actions

I discovered that Cloudflare provides a GitHub Action called cloudflare/pages-action that can deploy to Cloudflare Pages.

Perfect! I can create a GitHub Actions workflow that deploys to Cloudflare Pages from any pull request, including forks.

But I didn’t want it running automatically on every PR.

Why not? Several reasons:

  • Not all changes needed a preview deployment (like fixing a typo in a question)
  • Cloudflare’s free tier has usage limits
  • And most importantly, security concerns with running builds from forked PRs (which I will address later)

I needed a trigger I could activate manually, on demand from GitHub Mobile while on the road.

First thought: let’s use Pull request labels.

Labels are perfect because:

  • Only repository members can apply them. Meaning I could control when the workflow runs
  • They’re easy to add from GitHub Mobile with just a few taps

Here’s the workflow I started with:

name: Deploy Preview Site

on:
  pull_request:
    types:
      - labeled

permissions:
  contents: read
  pull-requests: write

jobs:
  deploy:
    name: Deploy to Cloudflare Pages
    if: github.event.label.name == 'request-preview-deployment'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0
      
      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.117.0'
      
      - name: Build site
        run: hugo

      - name: Deploy to Cloudflare Pages
        id: deploy
        uses: cloudflare/pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          projectName: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
          directory: 'public/'
          branch: ${{ github.event.pull_request.head.ref }}

      - name: Create comment
        uses: peter-evans/create-or-update-comment@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            **✅ Preview Environment Ready!**
            
            | Name | Link |
            | --- | --- |
            | :link: Preview Url | ${{ steps.deploy.outputs.alias }} |
            
            Please verify everything looks as expected.

          reactions: rocket

The flow is straightforward and mimics native Cloudflare preview environments:

  1. Trigger: I review the PR and apply the request-preview-deployment label if I need a visual check.
  2. Deploy: The workflow builds the website and deploys it to Cloudflare Pages.
  3. Notify: It posts a comment on the PR with the direct preview URL.

Now… if you worked a bit with GitHub Actions you might notice why this won’t work

Here is a hint, try to figure it out based on the screenshot below:

Cloudflare errors due to missing credentials on pull_request event

So why doesn’t it work?

Even though I’m passing ${{ secrets.CLOUDFLARE_API_TOKEN }} to the action, and I do know that secret exists in my repository settings, the action fails with authentication errors.

Reason: GitHub doesn’t expose repository secrets to workflows triggered by pull_request events from forked repositories.

This is a security feature to prevent malicious actors from stealing your secrets by submitting a PR that exfiltrates them.

Sooo… what now?

At the time of writing this blog (over a year after my initial implementation), I know of at least two ways to work around this limitation.

Let’s first explore the approach I took back then - before departing for my road trip.

Using potentially dangerous trigger

GitHub offers another event trigger for pull requests - pull_request_target.

Unlike workflow runs originating from the pull_request trigger, those triggered by the pull_request_target event run in the context of the base repository. This means:

  • ✅ Repository secrets are accessible (even from forked PRs)
  • ⚠️ Dangerous if you check out and run fork code

From the workflow above I had to change the event trigger:

on:
  pull_request_target: # instead of pull_request
    types:
      - labeled

and update the checkout step to checkout the forked repository:

      - uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0
          # Potentially dangerous, shouldn't be used without the label trigger
          repository: ${{ github.event.pull_request.head.repo.full_name }} 
          ref: ${{ github.event.pull_request.head.ref }} 

⚠️ Security Warning: This approach is considered a dangerous practice.

By combining pull_request_target (which grants access to secrets) with an explicit checkout of the PR’s code, you are effectively running untrusted code with elevated permissions.

I won’t dive deep into the specifics here, but I highly recommend reading this article from GitHub Security Lab: Keeping your GitHub Actions and workflows secure.

Here’s the key:

  1. The workflow only runs when I explicitly trigger it by adding the label
  2. I would always review the code changes first before adding it.

Essentially, the label acts as a manual approval gate. If there were any suspicious changes in the PR, I simply wouldn’t add the label and the workflow wouldn’t run.

Is this ideal?
Definitely not.

Was there another way?
Yes.

Did I know about it?
No.

So that’s what I went with. That’s the workflow that was available to me when me and my wife were abroad for several months.

How did it go? Let’s find out…

Real-world test: deploying from Vietnam

Fast forward a few months, me and my wife have already bought our motorbike in Vietnam. I can’t remember exactly where we were, but one day I opened my GitHub Mobile app to review a PR that came in and something didn’t look right with one of the practice test questions being added.

I proceeded to add the request-preview-deployment label from my phone:

After a moment, the workflow kicked in. The preview environment was created and I received a URL to check it out myself.

PR comment with link to preview website

The PR was adding a new GitHub Copilot certification question using single-choice syntax but marked two answers as correct which caused it to not render properly.

App could not render due to malformed question syntax

The preview deployment confirmed my suspicions: the question was malformed, breaking the practice test’s UI 💥.

I requested changes, but the submitter ultimately chose to close the PR rather than fix it.

Still, the system worked. The broken code never reached the main branch, saving me from headaches while trying to enjoy my vacation 🌴.

I count that as a win! 🏆

The Evolution: Two-Stage Deployment

Remember how I mentioned knowing at least two ways to run workflows that need secrets from forks?

Since returning from my trip, I’ve rebuilt the workflow to mitigate using the pull_request_target event trigger.

The flow remains the same from a user perspective—I still add a label to request a preview deployment.

What changed is that now I use a two-stage workflow pattern that separates the build process from the privileged deployment step.

Stage 1: The Unprivileged Build

This stage is triggered by pull_request events when the request-preview-deployment label is added.

Because we returned to using the pull_request event, secrets are not exposed to forks. This stage does not deploy to Cloudflare directly. Instead, it builds the site and uploads the static HTML as a GitHub Actions artifact.

Here is the complete workflow file for this stage (also available here on GitHub):

name: Preview Build

on:
  pull_request:
    types:
      - labeled

permissions:
  contents: read

jobs:
  build:
    name: Build Hugo Site
    if: github.event.label.name == 'request-preview-deployment'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout PR code
        uses: actions/[email protected]
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Setup Hugo
        uses: peaceiris/[email protected]
        with:
          hugo-version: '0.145.0'

      - name: Build site
        run: hugo

      - name: Upload build artifacts
        uses: actions/upload-artifact@v6
        with:
          name: hugo-build
          path: public/
          retention-days: 1

Stage 2: The Privileged Deploy

This stage is triggered after the completion of Stage 1 via the workflow_run event.

Workflow runs triggered by workflow_run events run in the context of the base repository (similarly to pull_request_target), so secrets are accessible.

However, in this scenario we do not check out any untrusted code. Instead, it simply downloads the artifact produced in Stage 1 and deploys it to Cloudflare Pages.

Here is the workflow configuration for the deployment stage (also available here on GitHub):

name: Preview Deploy

on:
  workflow_run:
    workflows: ["Preview Build"]
    types:
      - completed

permissions:
  contents: read
  actions: read
  deployments: write
  pull-requests: write

jobs:
  deploy:
    name: Deploy to Cloudflare Pages
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      - name: Download build artifacts
        uses: actions/download-artifact@v7
        with:
          name: hugo-build
          path: public/
          github-token: ${{ secrets.GITHUB_TOKEN }}
          run-id: ${{ github.event.workflow_run.id }}

      - name: Deploy to Cloudflare Pages
        id: deploy
        uses: AdrianGonz97/refined-cf-pages-action@v1
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          githubToken: ${{ secrets.GITHUB_TOKEN }}
          projectName: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
          directory: public

This pattern makes deploying from forks secure by design. Untrusted code stays in the unprivileged build stage, while secrets stay in the privileged deployment stage—meeting in the middle only as a static artifact.

I’ve also replaced the cloudflare/pages-action that has been since deprecated with AdrianGonz97/refined-cf-pages-action that offers better support for this two-stage deployment pattern and automatically creates PR comments with the preview URL.

PR comment generated by refined-cf-pages-action

Summary

So, bringing it back to the trip that started it all…

I used the original workflow exactly once during what turned out to be a 4-month trip.

So, was it worth it?

🔥 Hell yeah!

Not necessarily because I caught that one syntax error—I probably would have spotted that without the preview deployment.

It was worth it for the learning opportunity and simply because I wanted to do it. Sometimes that’s all you need.

So next time you have an idea, just go build it. You might learn something new or have a cool story to tell.