An APT repository is just a bunch of static files: a pool/ with .deb packages, a dists/ tree with Packages indexes, and a signed InRelease file on top. Static files are exactly what GitHub Pages serves, for free and over HTTPS. That makes Pages an attractive place for a small repository, as in my Zoom repository.
Under Settings → Pages → Build and deployment, GitHub offers two sources: Deploy from a branch and GitHub Actions. For a website the choice is mostly about the build tool. For an APT repository it decides where your packages are stored, where your signing key lives, and what happens to the repository after a year of releases. This post compares both.
The short version
- Branch: the finished repository (index, signatures and usually the
.debfiles) is committed to a branch such asgh-pages. Pages publishes whatever is in that branch. The content is versioned in git. - GitHub Actions: a workflow builds the repository in a directory on the runner, uploads it as an artifact, and deploys it. Nothing generated is committed. Each deployment replaces the whole site.
Everything else follows from this difference: in the branch approach the state of the repository is in git, in the Actions approach it has to be rebuilt (or fetched from somewhere else) on every run.
Approach 1: deploy from a branch
This is the classic way. You pick a branch and a folder (/ or /docs), and every push to it triggers GitHub’s built-in “pages build and deployment” run. The repository can be built anywhere: on your laptop, on a build server, or in a workflow that commits the result. A minimal update from a local machine looks like this:
# once: an orphan branch that shares no history with your code
$ git switch --orphan gh-pages
$ touch .nojekyll
# for every release
$ mkdir -p pool/main dists/stable/main/binary-amd64
$ cp ../build/*.deb pool/main/
$ apt-ftparchive packages pool > dists/stable/main/binary-amd64/Packages
$ gzip -9kf dists/stable/main/binary-amd64/Packages
$ apt-ftparchive \
-o APT::FTPArchive::Release::Suite=stable \
-o APT::FTPArchive::Release::Components=main \
-o APT::FTPArchive::Release::Architectures=amd64 \
release dists/stable > dists/stable/Release
$ gpg --clearsign -o dists/stable/InRelease dists/stable/Release
$ git add -A && git commit -m "Add release 1.2.3" && git push origin gh-pages
The .nojekyll file matters: without it, Pages runs the branch through Jekyll, which drops files and directories starting with _ or . and adds time to every build. Tools like reprepro or aptly work the same way: let them write the repository into the checkout, then commit and push.
Advantages
- The signing key can stay offline. You can sign
InReleaseon your own machine, even with a key on a smartcard or YubiKey, and push only the signed result. GitHub never sees the private key. For a repository that other people add to theirsources.list, this is the strongest argument. - State is simple. Old packages stay in the branch until you delete them. Adding a release means adding a file, not reconstructing the whole repository.
- Everything is visible and auditable.
git log gh-pagesshows which package was published when, and who pushed it. The published content is exactly what’s in the branch. - Easy rollback. A broken release is a
git revertand a push away. - No workflow required. Works with any build process and any tool that can run
git push.
Disadvantages
- Binaries in git. Every
.debstays in the history forever, even after you remove it from the branch. Each clone of the repository downloads all of it. GitHub warns at 50 MB per file and rejects files over 100 MB, and recommends keeping repositories below 1 GB. Git LFS is no way out: Pages serves the LFS pointer files, not the content. - Generated files in the repository.
Packages,ReleaseandInReleasechange with every release, so the history fills up with noise. Keeping it on a separate orphan branch helps, but it’s still in the same repository. - Concurrent updates conflict. If two builds (say, amd64 and arm64) both push to
gh-pages, the second one has to rebase and regenerate the index, or it overwrites the first one’s entries. - Build limits. Branch builds have a soft limit of 10 builds per hour. For a package repository that’s rarely a problem, but a burst of automated pushes can hit it.
If the history grows too large, a common trick is to keep gh-pages at a single commit: git commit --amend and git push --force for each release. The repository stays small, but you give up the history and the easy rollback, which were two of the main reasons to use a branch.
Approach 2: GitHub Actions as the source
Here the publishing source is a workflow. It builds the repository into a directory, actions/upload-pages-artifact packs that directory, and actions/deploy-pages publishes it. No branch holds the result:
name: Publish APT repository
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Build and sign repository
env:
REPO_GPG_PRIVATE_KEY: ${{ secrets.REPO_GPG_PRIVATE_KEY }}
run: |
echo "$REPO_GPG_PRIVATE_KEY" | gpg --batch --import
./scripts/build-repo.sh public/
- uses: actions/upload-pages-artifact@v4
with:
path: public/
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
build-repo.sh contains the same apt-ftparchive and gpg steps as above, just with --batch and an output directory.
Advantages
- No binaries or generated files in git. The repository contains scripts and configuration, and stays small no matter how many releases you publish.
- Reproducible. The published repository is a function of the source branch. Delete the Pages site, run the workflow, and it’s back.
- Atomic deployments. The whole site is replaced in one step. Clients never see a new
InReleasenext to an oldPackages. Theconcurrencygroup also serializes runs, so there are no push conflicts. - Fully automated. The repository can be updated on a schedule, on a tag, or whenever an upstream project releases. That is what makes the Zoom repository work without anybody touching it.
- Deployment controls. The
github-pagesenvironment supports protection rules, for example only allowing deployments frommainor requiring an approval.
Disadvantages
- The signing key lives at GitHub. The workflow can only sign the index if the private key is available as an Actions secret. Anyone who can change the workflow on a branch that is allowed to deploy can sign with it. Use a dedicated key without other purposes, restrict who can push, and use environment protection rules.
- Where do old packages come from? This is the central question. Each deployment replaces everything, and the Pages artifact is only kept for a short time. If the workflow builds only the current version, the previous one vanishes from the repository with the next deployment, and
apt install foo=1.2.2no longer works. You need a place for the state (see below). - Rollback is less direct. Re-running an old workflow run rebuilds from that commit, but if the run fetches packages from somewhere else, the result isn’t necessarily the old repository.
- More moving parts. Permissions, environment, two actions, a secret. And scheduled workflows are disabled after 60 days without activity in the repository, so a purely scheduled repository needs a keepalive.
Where to keep the packages
With the Actions source, the .deb files need a home that outlives a single run. The options I know of:
- GitHub Releases. Attach each
.debto a release (up to 2 GB per file). The workflow downloads all assets and builds the repository from them. Release assets don’t end up in the git history. - Leave them where they are. If the packages are already hosted somewhere stable, publish only the index on Pages and let apt fetch the packages from the original server using the
mirror+method. That’s what the Zoom repository does with Zoom’s CDN. The same trick works with release asset URLs, so the Pages site stays tiny: apt-repo-storytold publishes an index on Pages for the storytold “Craft” apps, while apt downloads the packages straight from the projects’ GitHub release pages. - Keep only index entries in git. Store the small control stanza of each package (a few hundred bytes) in the repository, not the package. Combined with one of the previous options, the workflow can rebuild the full index without downloading every package again.
- Only the latest version. Perfectly fine if nobody needs to downgrade.
Two options I’d avoid: actions/cache (entries are evicted after a week without use or when the cache is full) and downloading the currently deployed site before each build (one failed run with an empty site and the state is gone).
The hybrid: a workflow that pushes to a branch
The two settings describe how Pages publishes, not who builds. A workflow can just as well build the repository and commit it to gh-pages, for example with peaceiris/actions-gh-pages. You get the automation of approach 2 and the stored state of approach 1, but also the binaries in git and the signing key at GitHub. It’s a reasonable choice when you want automation and need old packages, but don’t want to set up a separate package store.
Side by side
| Deploy from a branch | GitHub Actions | |
|---|---|---|
| Published content | Committed to a branch | Built by a workflow, never committed |
| .deb files in git history | Yes (unless force-pushed away) | No |
| Signing key | Can stay offline / on a smartcard | Actions secret |
| Old package versions | Kept automatically | Need external storage |
| Rollback | git revert | Re-run an old build (if inputs still exist) |
| Concurrent updates | Push conflicts possible | Serialized via concurrency |
| Deployment | Per push, Jekyll unless .nojekyll | Atomic, no Jekyll |
| Per-file limit | 100 MB (git) | Only the 1 GB site limit |
| Automation | Optional | Built in |
Both share the general Pages limits: a published site of at most 1 GB and a soft bandwidth limit of 100 GB per month. Pages is meant for small projects, not for a mirror of a distribution.
When to choose which
Deploy from a branch when:
- you build and release by hand, every few weeks or months,
- the signing key must not leave your machine or your smartcard,
- the packages are small (a few MB each) and there aren’t many of them,
- you want a full, inspectable history of what was published.
Use GitHub Actions when:
- the repository should update itself, on a schedule or on new tags,
- the packages are large or frequent and would bloat git,
- the packages already live somewhere else (GitHub Releases, an upstream CDN) and Pages only needs to serve the index,
- you accept a dedicated signing key stored as a secret.
For my Zoom repository the decision was easy: it has to run unattended, the packages are 300 MB each and already on Zoom’s CDN, so the Actions source with an index-only site was the only sensible choice. For a small tool of my own, built a few times a year, I’d use a gh-pages branch and sign on my laptop.
