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.
terraform {
required_providers {
github = {
source = "integrations/github"
version = "< 7"
}
tls = {
source = "hashicorp/tls"
version = "< 5"
}
}
required_version = "< 2"
}
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.
The Terragrunt include block7 is a very useful feature that allows us to reuse the same configuration across multiple repositories.
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
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.
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
<!DOCTYPE html>
<html>
<head>
<title>Hello World</title>
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>
Applying this stack is the usual practice:
![Click to zoom in GitHub Pages Settings](../../../004-gh-pages-another-repo/assets/github-pages-settings.png)
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.
terraform {
required_providers {
github = {
source = "integrations/github"
version = "< 7"
}
}
required_version = "< 2"
}
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"
}
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
}
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"
}
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 bun
9 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:
- 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...
- 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.
- 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.
![Click to zoom in Commits in Target Repository](../../../004-gh-pages-another-repo/assets/pages-final-commits-snapshot.png)
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:
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