Skip to content

How to Publish to GitHub Pages From Another Repository

In this blog post, you will learn how to leverage GitHub Actions to deploy static files to the GitHub Pages of another repository.

This can be useful if you keep your source code in a private repository, but also, you may find additional reasons to need this setup.

Stick around till the end to find out how to do this with OpenTofu.

Introduction

The objective of this blog post is to deploy a static site from one GitHub repository to another.

There may be different kinds of reasons why you'd want to do that, but for starter, you might hold your source code (possibly written in a specific framework) inside a private repository.

On top of that, GitHub Pages is not available on private repositories unless you're paying some dollars 💸 to GitHub1. 🤑

All in all, there are various reasons why the approach provided in this blog post might be suitable for you.

As such, and just because I've recently gone through this exercise myself, and because GitHub does not officially support this feature2 (yet! 🤔), I will explain my implementation and provide you with all the goody OpenTofu codes. 🤗

Prerequisites

Make sure you have the following setup:

Preparation

We'll first look at how our Infrastructure as Code directory structure looks like.

./
├── 10-pages-repository
│   ├── files
│   │   ├── ci.yml
│   │   └── index.html
│   ├── main.tf
│   ├── outputs.tf
│   ├── terragrunt.hcl
│   └── versions.tf
├── 20-source-code-repository
│   ├── files
│   │   └── ci.yml.tftpl
│   ├── main.tf
│   ├── outputs.tf
│   ├── terragrunt.hcl
│   ├── variables.tf
│   └── versions.tf
└── github.hcl

Create The Pages Repository

We're ready to jump right in.

The first repository will be the target repo where we'll deploy our statics.

This is a must for our use-case to be a public repository. The benefit we get from this is that GitHub provides free GitHub Pages hosting for public repositories.

10-pages-repository/versions.tf
terraform {
  required_providers {
    github = {
      source  = "integrations/github"
      version = "< 7"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "< 5"
    }
  }
  required_version = "< 2"
}
10-pages-repository/main.tf
resource "github_repository" "this" {
  name       = "deploy-pages-target"
  visibility = "public"

  auto_init = true

  homepage_url = "https://developer-friendly.github.io/deploy-pages-target"

  pages {
    build_type = "workflow"
    source {
      branch = "main"
      path   = "/"
    }
  }

  lifecycle {
    ignore_changes = [
      vulnerability_alerts,
    ]
  }
}

resource "github_repository_file" "ci" {
  repository          = github_repository.this.name
  branch              = "main"
  file                = ".github/workflows/ci.yml"
  content             = file("${path.module}/files/ci.yml")
  commit_message      = "chore(CI): add pages deployment workflow"
  commit_author       = "opentofu[bot]"
  commit_email        = "opentofu[bot]@users.noreply.github.com"
  overwrite_on_create = true
}

resource "github_repository_file" "index_html" {
  repository          = github_repository.this.name
  branch              = "main"
  file                = "index.html"
  content             = file("${path.module}/files/index.html")
  commit_message      = "chore: add initial index.html"
  commit_author       = "opentofu[bot]"
  commit_email        = "opentofu[bot]@users.noreply.github.com"
  overwrite_on_create = true

  lifecycle {
    ignore_changes = [
      content,
    ]
  }

  depends_on = [
    github_repository_file.ci,
  ]
}

resource "tls_private_key" "deploy_key" {
  algorithm = "RSA"
  rsa_bits  = 4096
}

resource "github_repository_deploy_key" "this" {
  title      = "Pages Deployment"
  repository = github_repository.this.name
  key        = tls_private_key.deploy_key.public_key_openssh
  read_only  = false
}

Notice that the read_only is set to false because we want to grant git push access to the created Deploy Key6.

10-pages-repository/terragrunt.hcl
include "github" {
  path = find_in_parent_folders("github.hcl")
}

The Terragrunt include block7 is a very useful feature that allows us to reuse the same configuration across multiple repositories. 👇

github.hcl
generate "github" {
  path      = "provider_github.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<-EOF
    provider "github" {
      owner = "developer-friendly"
    }
  EOF
}

Important Note

The owner configuration in the provider block8 will mean nothing unless you are authenticated to GitHub using your GitHub CLI5!

Additionally, you will require at least the following scopes:

  • repo
  • workflow
  • admin:repo_hook
10-pages-repository/outputs.tf
output "repository_full_name" {
  value = github_repository.this.full_name
}

output "deploy_private_key" {
  value     = tls_private_key.deploy_key.private_key_pem
  sensitive = true
}

The following CI file will get triggered as soon as our source repository pushes the statically built files into the root of the repository; this workflow, in turn, is responsible for deploying those static files behind the GitHub CDN.

10-pages-repository/files/ci.yml
name: ci

concurrency:
  cancel-in-progress: true
  group: ci-${{ github.ref_name }}-${{ github.event_name }}

on:
  push:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup Pages
        uses: actions/configure-pages@v5
      - name: Upload Pages artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: .
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4
10-pages-repository/files/index.html
<!DOCTYPE html>
<html>
  <head>
    <title>Hello World</title>
  </head>
  <body>
    <h1>Hello, World!</h1>
  </body>
</html>

Applying this stack is the usual practice:

terragrunt init -upgrade
terragrunt plan -out tfplan
terragrunt apply tfplan
GitHub Pages Settings
GitHub Pages Settings

Now we're ready to create the next repository.

Source Code Repository

This is the repository where we'll store our source codes written in React, Vue, or any other framework of your choosing.

You can choose to make this one private and it will still work with the ideas presented here, however, for the sake of demonstration, we'll stick to public for both repositories.

20-source-code-repository/versions.tf
terraform {
  required_providers {
    github = {
      source  = "integrations/github"
      version = "< 7"
    }
  }
  required_version = "< 2"
}
20-source-code-repository/variables.tf
variable "pages_repository_full_name" {
  type        = string
  nullable    = false
  description = "In the format OWNER/REPOSITORY"
}

variable "pages_deploy_private_key" {
  type      = string
  sensitive = true
  nullable  = false
  description = "SSH private key with write access to the repository of GitHub Pages"
}
20-source-code-repository/main.tf
resource "github_repository" "this" {
  name       = "deploy-pages-source"
  visibility = "public"

  auto_init = true

  lifecycle {
    ignore_changes = [
      vulnerability_alerts,
    ]
  }
}

resource "github_repository_file" "ci" {
  repository          = github_repository.this.name
  branch              = "main"
  file                = ".github/workflows/ci.yml"
  content             = templatefile("${path.module}/files/ci.yml.tftpl", {
    repository_full_name = var.pages_repository_full_name
  })
  commit_message      = <<-EOF
    chore(CI): add build workflow

    [skip ci]
  EOF
  commit_author       = "opentofu[bot]"
  commit_email        = "opentofu[bot]@users.noreply.github.com"
  overwrite_on_create = true
}

resource "github_actions_secret" "deploy_key" {
  repository       = github_repository.this.name
  secret_name      = "GH_PAGES_SSH_PRIVATE_KEY"
  plaintext_value  = var.pages_deploy_private_key
}
20-source-code-repository/terragrunt.hcl
include "github" {
  path = find_in_parent_folders("github.hcl")
}

inputs = {
  pages_repository_full_name = dependency.pages_repo.outputs.repository_full_name
  pages_deploy_private_key   = dependency.pages_repo.outputs.deploy_private_key
}

dependency "pages_repo" {
  config_path = "../10-pages-repository"
}
20-source-code-repository/files/ci.yml.tftpl
name: ci

concurrency:
  cancel-in-progress: true
  group: ci-$${{ github.ref_name }}-$${{ github.event_name }}

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout the source repository
        uses: actions/checkout@v4
      - name: Setup bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest
      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: node_modules
          key: $${{ runner.os }}-bun-$${{ hashFiles('**/bun.lockb') }}
          restore-keys: |
            $${{ runner.os }}-bun-
      - name: Install dependencies
        run: bun install
      - name: Build
        run: bun run build
      - name: Persist built statics in ephemeral filesystem
        id: site-dir
        run: |
          tempdir="$(mktemp -d)"
          echo "site-dir=$tempdir" >> "$GITHUB_OUTPUT"
          rsync -azuvb dist/ "$tempdir/"
      - name: Upload artifact
        id: upload-artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist-$${{ github.ref_name }}
          path: dist
      - name: Clone the Pages repository
        uses: actions/checkout@v4
        with:
          repository: ${repository_full_name}
          ssh-key: $${{ secrets.GH_PAGES_SSH_PRIVATE_KEY }}
      - name: Setup target repository ssh private key
        run: |
          mkdir -p ~/.ssh
          cat <<'EOF' > ~/.ssh/github-deploy-key
          $${{ secrets.GH_PAGES_SSH_PRIVATE_KEY }}
          EOF
          chmod 600 ~/.ssh/github-deploy-key
      - name: Setup git config
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "$${{ github.run_id }}+github-actions[bot]@users.noreply.github.com"
          cat <<'EOF' > ~/.ssh/config
          Host github.com
            HostName github.com
            User git
            IdentityFile ~/.ssh/github-deploy-key
          EOF
      - name: Push statics to Pages repository
        run: |
          git ls-files | grep -v -e "^CNAME$" -e "^.github/" | xargs git rm -rf
          rsync -azuvb $${{ steps.site-dir.outputs.site-dir }}/ .
          git add .
          if [ -z "$(git status --porcelain)" ]; then
            echo "No changes to commit"
          else
            git commit -m "chore: deploy $${{ github.sha }}"
            git push origin "$(git branch --show-current)"
          fi

You are not bound to just bun9 and can choose any other static site builder. I am more comfortable and prefer bun for its simplicity.

In the second CI workflow definition, the steps should be self-explanatory. However, to avoid being presumptuous, I will highlight the importance of the following two steps:

20-source-code-repository/files/ci.yml.tftpl
      - name: Persist built statics in ephemeral filesystem
        id: site-dir
        run: |
          tempdir="$(mktemp -d)"
          echo "site-dir=$tempdir" >> "$GITHUB_OUTPUT"
          rsync -azuvb dist/ "$tempdir/"

And then later...

20-source-code-repository/files/ci.yml.tftpl
      - name: Push statics to Pages repository
        run: |
          git ls-files | grep -v -e "^CNAME$" -e "^.github/" | xargs git rm -rf
          rsync -azuvb $${{ steps.site-dir.outputs.site-dir }}/ .
          git add .
          if [ -z "$(git status --porcelain)" ]; then
            echo "No changes to commit"
          else
            git commit -m "chore: deploy $${{ github.sha }}"
            git push origin "$(git branch --show-current)"
          fi

The reason we store the files in an ephemeral filesystem path is that the actions/checkout in between them will wipe everything in the current working directory.

20-source-code-repository/files/ci.yml.tftpl
      - name: Clone the Pages repository
        uses: actions/checkout@v4
        with:
          repository: ${repository_full_name}
          ssh-key: $${{ secrets.GH_PAGES_SSH_PRIVATE_KEY }}

That is, the statically built files we created with bun run build will be cleared once checkout finishes its executation.

Develop and Deploy Frontend Code

At this stage, we go into the normal flow of a software engineer, create some feature, commit it to the source code and push it to the repository.

gh repo clone developer-friendly/deploy-pages-source
cd deploy-pages-source/

# only for the first time
bun init -y

bun install
bun i vite@latest -D

echo "Hello again, this time from the source repository" | tee index.html

git add .
git commit -m 'chore: initial commit'
git push origin $(git branch --show-current)

And the result is as expected.

Commits in Target Repository
Commits in Target Repository

404 Not Found

For single page applications, since GitHub doesn't provide native suppot (yet!), you might wanna do a bit of hack!

It can be as simple as creating a symbolic link to the index.html file:

ln index.html 404.html

Or it might be a bit more involved, using some JavaScript workaround10.

For a better experience in your daily development, you might also benefit from the Slack GitHub Integration11. That gives you the ability to subscribe to different triggers in both the repositories.

Conclusion

In this piece you've seen how to leverage the currently available tools in the GitHub ecosystem to deploy static files from one repository to another.

This is a cool workaround to take advantage of free GitHub Pages hosting for your frontend applications if you're on a budget.

Additionally, you might just use this method for your preview deployments or as a auxiliary deployment strategy.

If you enjoyed this piece and read all the way down here, you might wanna subscribe to the newsletter or the rss feed. 😉

Until next time, ciao 🤠 & happy coding! 🐧 🦀

If you enjoyed this blog post, consider sharing it with these buttons 👇. Please leave a comment for us at the end, we read & love 'em all. ❣

Share on Share on Share on Share on

Comments