Hosting a Photography Portfolio on GitHub for Free
My photography gallery currently serves 515 full-resolution photos, about 1.5 GB of images, to anyone who clicks on them. My monthly hosting bill for this is zero.
The catch is that GitHub Pages, which hosts this entire site, has a hard rule: a published site may be no larger than 1 GB. My photo collection is bigger than my entire allowance, ignoring all the blog content, and the rest of this website. This post is about GitHub releases.
Quick jargon guide
- GitHub Pages: GitHub's free static-site hosting — push HTML to a repository and it becomes a website. It's what serves the page you're reading.
- Repository (repo): a project folder tracked by git, holding every file and the full history of every change ever made to it.
- Release: a snapshot of a repository at a point in time, to which you can attach downloadable files ("release assets") — normally installers and binaries, in this post something else entirely.
- Git LFS: Large File Storage, GitHub's official add-on for versioning big files. Solves repo bloat by charging you money instead.
- Egress: cloud-billing jargon for data leaving a provider's servers — i.e. what it costs when someone downloads your photo. The line item that quietly dominates image hosting bills.
- Manifest: a plain list of files (here, a JSON file naming every photo) that a page reads so it knows what exists without asking a server to enumerate anything.
- WebP: a modern image format that compresses far smaller than JPEG or PNG at similar quality — ideal for thumbnails.
- CLIP: an OpenAI model that matches images against text descriptions, which makes it a free, automatic photo-tagger.
Hitting the wall
When I first added my photography to this site, I exported the photos and committed them to the repository like any other asset. It worked, for a good few years. Then at some random point well past the 1GB limit, GitHub let me know, politely but firmly, that this was no longer going to fly.
Worse, the damage outlives the mistake. Git never forgets: even after I removed the full-size photos from the site, every committed version of every photo remains in the repository's history. My .git pack sits at 4.91 GiB to this day, a monument to the months when I treated a version control system like a hard drive. (You can scrub history with tools like git filter-repo, but every clone, fork, and local copy has to be reconciled with the rewrite, and for a personal site it has so far been easier to live with the scar tissue.)
So the problem statement became: keep the site on GitHub Pages, keep the photos at full resolution, pay nothing, and stay under 1 GB. The numbers to beat, for the curious:
- 1 GB — maximum size of a published GitHub Pages site (and the recommended ceiling for the repository itself).
- 100 MB — maximum size of any single file pushed to a repository.
- 100 GB/month — the soft bandwidth guideline for Pages sites.
- 2 GiB — maximum size of a single release asset, with no documented cap on the total size of a release.
So: releases are a free bucket
GitHub releases exist so projects can attach compiled binaries to a version tag: you tag v1.0, you upload the installers, users download them. But strip away the intent and a release is just an object store bolted onto your repository. Files up to 2 GiB each, no total-size cap in the documentation, served from GitHub's download infrastructure rather than your Pages allowance, and addressable by a clean, predictable URL:
https://github.com/<user>/<repo>/releases/download/<tag>/<filename>
So my full-size photos live in a release called photos-v1, attached to this very website's repository. Uploading is two commands with the gh CLI:
gh release create photos-v1 --title "Full-size photography" --notes "Gallery originals"
gh release upload photos-v1 photos/*.png
That release now holds all 515 originals, 1.55 GB of them, which is to say: the "attachment" stapled to my repository is bigger than the entire website is allowed to be. GitHub is fine with this. Releases are a supported distribution mechanism for a repository's files, and these are, quite literally, the files this repository's site is built around.
The full pipeline
The release solves storage, but you can't lazily browse a 1.5 GB folder from a phone. The other half of the architecture is the oldest idea in web galleries: thumbnails for browsing, originals on demand. Here's the whole pipeline, which is a handful of small Python scripts I run locally when I add photos:
- Normalise filenames. Every photo becomes a number:
417.png. NoIMG_20250612_final_FINAL(2).jpg. Numeric names make the manifest trivial and the URLs boring, and boring URLs are the ones that keep working. - Generate thumbnails. Each original gets a compressed WebP thumbnail (
img/photography/thumb/417.webp). These do live in the repository, all 492 of them, because collectively they're small enough to sit comfortably inside the Pages budget. This is the only image data the site itself hosts. - Auto-tag with CLIP. A local script runs every photo through OpenAI's CLIP model to classify it (landscape, urban, wildlife, winter…) and writes the results to a JSON file. I wrote about this in its own post; those tags drive the gallery's filter buttons, and I never manually categorise anything.
- Write a manifest. A JSON list of every filename in the release. The gallery page fetches this instead of asking the GitHub API, so the site works even when the API is rate-limiting.
- Upload the originals to the release with
gh release upload, and commit the thumbnails plus the two JSON files.
At view time, the gallery JavaScript loads the manifest, renders thumbnails in batches of 24 as you scroll, and each lightbox link points at the release URL for the original. The visitor's browser talks to GitHub's release servers directly; my Pages bandwidth only ever carries the thumbnails.
Why not the "proper" solutions?
Git LFS is GitHub's own answer to large files, and it would have been the natural choice, except the free tier is a single gigabyte of storage and a similarly small monthly bandwidth allowance, after which you're buying data packs. It solves the repository-bloat problem while replacing "free" with "subscription".
Object storage (S3, Cloudflare R2, Backblaze B2) is a good answer and I'd use it for anything commercial. But it means an account, a bucket policy, a billing alarm, and a second system to maintain for a personal site whose entire ethos is "one repository contains everything". My gallery's originals sit next to the code that serves them, in the same repo, behind the same login, and I'm lazy in odd ways.
Photo platforms solve a different problem — community and discovery — and charge accordingly. For raw hosting, here's the rough landscape as of mid-2026 (prices drift; check before you commit):
| Option | Rough cost for ~1.5 GB + modest traffic | Notes |
|---|---|---|
| Flickr Pro | ~$70–90/year | Unlimited storage, but your portfolio lives on their site, in their design. |
| SmugMug | ~$100+/year | Polished portfolio hosting; genuinely good, genuinely not free. |
| Amazon S3 + egress | Pennies for storage, ~$0.09/GB served | The bandwidth line item is the one that surprises people. |
| Cloudflare R2 | ~Free at this scale | Free egress and a generous free tier; the strongest alternative. Still a second account and billing surface. |
| GitHub release | $0 | Everything in one repo. You're reading the case study. |
The caveats
- No contract. GitHub documents release assets as a distribution feature; they don't promise it as a CDN. If your traffic looked like abuse, they'd be within their rights to object. A personal portfolio's click-through traffic is nowhere near that territory, but a startup's image backend would be.
- Public means public. Release assets on a public repo are accessible to anyone with the URL. Fine for a portfolio, since a portfolio's job is to be seen; wrong for anything private.
- URLs are tied to the repo. Rename your account or repository and every deep link changes. Choose boring, stable names.
- No image processing. S3-with-a-CDN setups can resize on the fly. Here, every size you serve is a size you generated yourself. I need exactly two: thumbnail and original.
- Don't commit the originals, ever. The release replaces the repo for full-size files, it doesn't supplement it. Learn from my 4.91 GiB of permanently embarrassing git history.
Constraints make better sites
The 1 GB limit made the gallery better. Being forced to split hot data (thumbnails, manifest, tags) from cold data (originals) is just good architecture, the same shape as any cache-and-archive system. The gallery loads fast on bad connections because it physically cannot ship the heavy files up front.
Total infrastructure: one repository, one release, three JSON files, and a few local Python scripts. Total cost: nothing. The photos are backed up, versioned adjacent to the site that shows them, and served by one of the most reliable download infrastructures on the internet.
Common questions
Is this against GitHub's terms of service?
Releases are for distributing a repository's files, and these are the files this repository's website is built from, so I'm comfortable it's within both the letter and spirit. What the terms do prohibit is using releases as a general-purpose CDN for content unrelated to the repo, or serving traffic at a scale that disrupts the service. Host your portfolio, not your startup's user uploads.
What happens if GitHub kills the loophole?
Then I run one script to re-upload 515 files somewhere else (probably Cloudflare R2) and change one URL prefix in one JavaScript file.
Why PNG originals instead of JPEG or WebP?
Honestly, PNG is a lossless container I trust for archival, and storage is (see above) free.
Could I do this without the command line?
Yes — releases can be created and files uploaded entirely through GitHub's web interface (Releases → Draft a new release → drag files in). The CLI just stops being optional somewhere around your fiftieth photo.