Posted on

DIY PR Preview Deployments: It's Easier Than You Think!

Let's preface this by admitting that the title is a little farcical - the fact that Vercel and other similar sites give you preview domains out of the box with no setup is a lovely feature. This blog is purely to show you that it's actually possible to configure these yourself, and furthermore, it's way easier than you might think!

To give you some context, my site pert.dev is built on Zola - a static site which I serve on my $5 VPS from Linode, using Caddy. I use Cloudflare as my domain registrar and for DNS. While my setup (which I'll link to at the end if you want to skip ahead) is specific to these tools, my goal for this blog is to demonstrate that the core technologies for setting this up yourself are actually easy-peasy, regardless of your stack.

PR-Previews: The Big Picture

At their heart, PR previews are surprisingly simple and can be broken down into three main components. We'll dive deep into each one, but here's the overview:

  1. DNS Management: Creating a new A record for our preview subdomain
  2. File Deployment: Getting our PR changes somewhere they can be served
  3. Web Server Config: Telling our web server how to serve these files

The beauty is that these concepts apply whether you're running Next.js, Ruby on Rails, or a static site like mine.

Initial Setup

First, let's create the files we'll need in our repository:

mkdir -p .github/workflows
touch .github/workflows/pr-preview.yml

We'll give this workflow some basic setup to run on PRs:

name: PR Preview

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  pull-requests: write

jobs:
  deploy-preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Get PR number
        id: pr
        run: echo "PR_NUMBER=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT

      - name: Deploy Preview
        run: |
          chmod +x ./scripts/preview.sh
          ./scripts/preview.sh

I'm not going to deep-dive into GitHub Actions here, but we've added the essential steps: checkout the repo, grab the PR number (which we'll use later), and run our deployment script.

Next, let's create our script file:

mkdir scripts # if it doesn't exist already
touch scripts/preview.sh
chmod +x scripts/preview.sh

Part 1: DNS Magic - Easier Than You Think

You don't need to be a DNS wizard to get this working, but if you're curious about the details, I'll point you to some great resources on DNS basics.

This part assumes two things:

  • You own the domain and can update DNS records
  • You have API access to your DNS provider (I'll show Cloudflare, but most providers have similar APIs)

PR previews typically use a subdomain pattern like pr-123.yourdomain.com. Creating this is surprisingly straightforward - we just need an A record pointing to our server's IP. If your site is already live, you probably have something like:

A record
yourdomain.com (or @)
253.253.253.253

For our preview, we'll create a similar record but with our PR subdomain. Here's how we do it via the Cloudflare API:

setup_dns() {
    local preview_id=$1
    local server_ip="253.253.253.253"
    
    # Check if record exists
    local existing_record=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records?name=${preview_id}.yourdomain.com" \
        -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
        -H "Content-Type: application/json")
    
    if echo "$existing_record" | grep -q "\"count\":0"; then
        # Create new record
        curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records" \
            -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
            -H "Content-Type: application/json" \
            --data "{
                \"type\": \"A\",
                \"name\": \"${preview_id}\",
                \"content\": \"${server_ip}\",
                \"ttl\": 1,
                \"proxied\": true
            }"
    else
        # Update existing record
        local record_id=$(echo "$existing_record" | grep -o '"id":"[^"]*' | cut -d'"' -f4)
        curl -s -X PUT "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${record_id}" \
            -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
            -H "Content-Type: application/json" \
            --data "{
                \"type\": \"A\",
                \"name\": \"${preview_id}\",
                \"content\": \"${server_ip}\",
                \"ttl\": 1,
                \"proxied\": true
            }"
    fi
}

We'll need to pass some environment variables from our GitHub Actions:

      - name: Deploy Preview
        env:
          PREVIEW_ID: ${{ steps.pr.outputs.PR_NUMBER }}
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
        run: |
          chmod +x ./scripts/preview.sh
          ./scripts/preview.sh

The Cloudflare API token and Zone ID should be stored as GitHub secrets.

Part 2: Deploying Your Changes

This part will vary depending on your stack, but the principle is the same: get your PR changes somewhere they can be served. I'll show you my Zola setup, but you can adapt this for any framework.

In my case, Zola builds a /public directory with all my static files. Your build process might look different:

      - name: Build site
        run: |
          # npm run build  # for Next.js/React
          # yarn build     # for Gatsby
          # bundle exec jekyll build  # for Jekyll
          zola build      # my case

Now we need to get these files to our server. Here's a simple but effective approach using nothing other than good old scp:

deploy_files() {
    local preview_id=$1
    
    # Ensure preview directory exists
    ssh "$USER@$HOST" "sudo mkdir -p /var/www/previews/${preview_id}"
    
    # Create temp directory for transfer
    ssh "$USER@$HOST" "mkdir -p /tmp/${preview_id}"
    
    # Copy built files
    scp -r public/* "$USER@$HOST:/tmp/${preview_id}/"
    
    # Move files to final location
    ssh "$USER@$HOST" "sudo rm -rf /var/www/previews/${preview_id}/* && \
                       sudo mv /tmp/${preview_id}/* /var/www/previews/${preview_id}/ && \
                       rm -rf /tmp/${preview_id}"
}

Add the SSH credentials to your GitHub Action:

      - name: Deploy Preview
        env:
          HOST: ${{ secrets.SERVER_HOST }}
          USER: ${{ secrets.SERVER_USER }}
          PREVIEW_ID: ${{ steps.pr.outputs.PR_NUMBER }}
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
        run: |
          chmod +x ./scripts/preview.sh
          ./scripts/preview.sh

Part 3: Web Server Configuration

The final piece is telling your web server about our new site. I'll show you how to do this with different servers:

First, create a directory for our preview configs:

# On your server
sudo mkdir -p /etc/caddy/conf.d  # for Caddy
# or
sudo mkdir -p /etc/nginx/sites-available  # for Nginx

For Caddy (my choice because it makes SSL super easy): Add this to your main Caddyfile:

import /etc/caddy/conf.d/*

Then create preview configs like this:

setup_caddy() {
    local preview_id=$1
    local config_content="${preview_id}.yourdomain.com {
        root * /var/www/previews/${preview_id}
        file_server
    }"
    
    # Add/update site configuration
    echo "$config_content" | ssh "$USER@$HOST" "sudo tee /etc/caddy/conf.d/${preview_id}.conf"
    
    # Reload Caddy
    ssh "$USER@$HOST" "sudo systemctl reload caddy"
}

For Nginx:

server {
    listen 80;
    server_name pr-123.yourdomain.com;
    root /var/www/previews/pr-123;
    
    location / {
        try_files $uri $uri/ =404;
    }
}

For Apache:

<VirtualHost *:80>
    ServerName pr-123.yourdomain.com
    DocumentRoot /var/www/previews/pr-123

    <Directory /var/www/previews/pr-123>
        Options Indexes FollowSymLinks
        AllowOverride None
        Require all granted
    </Directory>
</VirtualHost>

Putting It All Together

Our complete preview.sh script looks like this:

#!/bin/bash

setup_dns() {
    # DNS setup function from above
}

deploy_files() {
    # File deployment function from above
}

setup_webserver() {
    # Web server config function from above
}

main() {
    setup_dns "$PREVIEW_ID"
    deploy_files "$PREVIEW_ID"
    setup_webserver "$PREVIEW_ID"
}

main

Cleanup

One thing we haven't addressed is cleanup. When a PR is closed, we need to:

  1. Remove the DNS record
  2. Delete the preview files
  3. Remove the web server config

Create a new GitHub workflow for this:

name: Cleanup PR Preview

on:
  pull_request:
    types: [closed]

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Cleanup
        env:
          PREVIEW_ID: pr-${{ github.event.pull_request.number }}
          # Add other environment variables
        run: ./scripts/cleanup.sh

The complete cleanup scripts are available in my repo:

Conclusion

So there you have it! While fancy deployment platforms give you this for free, building your own preview system isn't as daunting as it might seem. With some basic systems knowledge and a few hours of work, you can have your own preview deployment system that:

  • Costs nothing extra if you're already self-hosting
  • Gives you complete control over the setup
  • Helps you understand what's actually happening under the hood

Want to see the complete implementation? Check out: