Hosting a website from a GitHub repo
In 2011 I made 22 free mobile website templates under the name Mobifreaks. For a while they ranked #1 on Google for "Free Mobile Website Templates". I was very proud of that. I still am, a little.
Fast forward a few years, and every demo existed in three versions. A 2014 gh-pages branch. A 2019 main branch. And a hand-copied, hand-fixed copy on munawarahmed.com. They had drifted apart, and the GitHub demos were quietly broken: dead lorempixel images, assets loaded over http, the works.
The fix was one rule: each repo is the only source of its demo. Push to main, and the demo updates itself. No copying, ever. Here is how that works, idea by idea. You can see the result on the Mobifreaks gallery.
GitHub Pages, and the switch that changed everything
GitHub Pages is free static hosting for a repo. HTML, CSS, JavaScript, images. No server code, no database. For a folder of mobile templates, that is all you need.
Pages can publish your site in two ways, and this is the part I wish someone had told me years ago.
Deploy from a branch is the old way. You pick a branch, usually gh-pages, and whatever is in it goes live. Simple, but the branch is a second copy of your site. You have to remember to update it. I didn't, which is why my demos were stuck in 2014 while the real code moved on.
GitHub Actions is the new way. A workflow builds the site and uploads it as an artifact, and Pages serves that artifact. There is no branch involved at all. The site is just "what main looks like, after the workflow has had its way with it".
That was my "aha" moment. No gh-pages branch means nothing to forget and nothing to drift. To turn it on, open the repo's Settings, then Pages, and set Source to GitHub Actions. That's the whole switch. (GitHub's docs on publishing sources cover the details.) After that, I could finally delete the old branches. Very satisfying.
GitHub Actions, in plain words
A workflow is a YAML file in .github/workflows/. You tell it when to run (say, on every push to main) and what steps to take. GitHub spins up a fresh machine, runs the steps, and throws the machine away.
Mine does four things that matter: check out the code, stage the files that belong in the demo, upload them as a Pages artifact, and deploy. Trimmed down, it looks like this:
jobs:
deploy:
runs-on: ubuntu-latest
environment: github-pages
steps:
- uses: actions/checkout@v4
- name: Stage site
run: |
mkdir -p _site
rsync -a --exclude '.git*' --exclude README.md ./ _site/
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: _site
- uses: actions/deploy-pages@v4
"Stage" just means copying the files a visitor needs into a clean _site folder, and leaving out the ones they don't (the README, the git bits). Then upload-pages-artifact packs that folder up, and deploy-pages puts it live.
The whole trip looks like this:
git push (main)
│
▼
demo.yml in the template repo
│ calls
▼
deploy-demo.yml in mobifreaks/.github
│ stage ─► checks ─► upload artifact
▼
GitHub Pages
│
▼
mobifreaks.munawarahmed.com/<repo>/
One workflow, 22 repos
Copying that workflow into 22 repos would have recreated the exact problem I was running away from. 22 copies, slowly drifting apart. No thanks.
GitHub has a fix for this: reusable workflows. You write the real workflow once, and other workflows call it like a function. Mine lives in the org's mobifreaks/.github repo. Each template repo gets a tiny caller, about 15 lines:
name: Demo
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
jobs:
demo:
uses: mobifreaks/.github/.github/workflows/deploy-demo.yml@v1
secrets: inherit
That uses: line is the magic. Fix a bug in the shared workflow once, and all 22 templates get it on their next push. You can see a finished one in Pomegranate-Business-Mobi.
Checks that keep me honest
Since every deploy goes through one pipe, I put a few checks in that pipe. The deploy fails if:
- the footer credit link back to munawarahmed.com is missing (that link is how Mobifreaks ranked in the first place, so it stays), or
- any local link or image is broken. lychee scans every page for this. Goodbye, dead lorempixel images.
One more trick. The demos are lorem ipsum in ten near-identical colours, so I don't want them in search results. The workflow adds a noindex meta tag at deploy time only. It is never committed, so people who download a template don't inherit it on their own site. The demo is hidden, the template is clean.
My own subdomain
By default, a project site lives at <org>.github.io/<repo>. That works, but the address belongs to GitHub. I wanted mine.
Here is the neat part. If you put a custom domain on the org's special <org>.github.io repo, every project site in that org moves under it. So with mobifreaks.munawarahmed.com set on mobifreaks.github.io, the Pomegranate demo now lives at mobifreaks.munawarahmed.com/Pomegranate-Business-Mobi/. Old github.io links redirect on their own, so years of bookmarks and stars still land in the right place.
DNS in GoDaddy, in the right order
My domain's DNS is at GoDaddy. Two records, and the order matters:
- Verify the domain on GitHub first. In the org's settings, under Pages, add
munawarahmed.com. GitHub gives you a TXT record to add in GoDaddy. Once it sees the record, the domain is verified. - Then add the CNAME. Host
mobifreaks, pointing tomobifreaks.github.io.
Why this order? A CNAME pointing at GitHub, on a domain nobody has verified, is an open invitation. Someone else could set that name as the custom domain on their repo and serve whatever they like on my subdomain. Verifying first means only my org can use it. (I've seen an old domain of mine end up somewhere I didn't choose. I'm not keen to repeat that.) GitHub explains it in verifying your custom domain.
Showing the demos without copying them
The last piece is munawarahmed.com itself. The gallery needs to know where each demo lives, but I promised myself: no copies.
So at build time, the site asks the GitHub API for the org's repos and keeps the ones with the topic portfolio-demo. Each repo's homepage field (the "Website" link in the repo sidebar) is the demo URL. The phone-frame preview on the gallery is an iframe pointing straight at it.
const repos = await (await fetch(api)).json();
const demos = repos.filter(
(r) => r.topics.includes('portfolio-demo') && r.homepage
);
So when a template is ready for the gallery, I don't touch my site at all. I add the topic on GitHub, paste the demo link into the Website field, and the next build picks it up. Two small edits, and it shows up.
And the old /mobifreaks/<slug>/ URLs, where the hand-made copies used to live? Those send a 301 to the new home through vercel.json, so nothing that linked to them breaks.
What tripped me up
The HTTPS certificate took about 30 minutes. In that window, github.io forwarded to the http version of my new subdomain, and my site's iframe was blocked as mixed content. Nothing was broken, it just looked broken. Lesson: switch the domain first, wait for the certificate, then point your site at it.
Renaming master to main finishes in the background. My migration script cloned three repos too early, before the rename had settled, and pushed to a freshly re-created master. Pages only deploys from the default branch, so those three demos failed until I cleaned it up. Lesson: after a rename, check the default branch before you push.
Redirect patterns are pickier than they look. Vercel's :path* rule happily caught /mobifreaks/dossier-mobi/index.html, but not the bare folder with a trailing slash, /mobifreaks/dossier-mobi/. That one needed its own rule. Lesson: test the boring URL, not just the deep one.
Push, and it's live
Today, if I fix a typo in a 2011 template, I push to main and walk away. The workflow checks it, Pages hosts it, my subdomain serves it, and the gallery picks it up. One source, zero copies. Iterate quickly, release often.
If you have an old project gathering dust in a repo, give it a demo.yml and switch the Pages source to GitHub Actions. It's a nice feeling, watching 15-year-old work come back to life.