An hourly mirror of all of CTAN on Cloudflare R2, served at
https://ctan.katoptra.org/ with every CTAN path at the root. About 511,000 files and
140 GB, synced from CTAN's master by a list diff: each hour lists upstream, compares it
with what the bucket already holds, and moves only the difference. It costs about $2 a
month, all of it storage, and runs entirely on GitHub Actions.
TeX Live and TinyTeX both use tlmgr:
tlmgr option repository https://ctan.katoptra.org/systems/texlive/tlnet/
tlmgr update --self --allFor a fresh install, give the installer the same URL:
install-tl -repository https://ctan.katoptra.org/systems/texlive/tlnet/Any directory URL lists what the mirror holds there:
https://ctan.katoptra.org/systems/knuth/. The root serves CTAN's own index page.
To go back to CTAN's mirror rotation: tlmgr option repository ctan.
Is it fresh? curl -s https://ctan.katoptra.org/timestamp prints the hour of the last
completed run, the same file CTAN's mirror monitor reads.
Once an hour a GitHub Actions job runs this pipeline inside the toolbox image from katoptra/lib. Every solid box is a verb of lib's rsync engine; the dashed ones are this mirror's own.
flowchart LR
clock --> list --> state --> rebuild --> diff --> split --> prepare --> batches
subgraph b["batches: the first MAX_BATCHES of the delta, each committed before the next"]
direction LR
fetch --> verify --> publish --> checkpoint
end
batches --> b --> delete --> reconcile --> index --> smoke --> report --> ping
index -.-> pages["pages"]
smoke -.-> sm["smoke-mirror"]
report -.-> rm["report-mirror"]
classDef own stroke-dasharray: 5 5
class pages,sm,rm,index own
What this mirror owns, in Taskfile.yml:
- Its identity, in root vars:
SOURCE(CTAN's master,rsync.dante.ctan.org),HOST,BUCKET, the signed TeX Live subtreeTLand its key fingerprintTL_KEY, a 200 GBCEILING_GBpast which a run refuses to start, and aLIST_FLOORunder which a listing is taken as truncated rather than as a deletion list. - Directory pages. R2 serves no listings, so
pagesdraws one for every directory the run touched, from the state file rather than from upstream, andindexuploads each under two keys:<dir>/ctan.katoptra.org.directory.index.html, which a zone rule serves for/dir/, and the bare<dir>, which serves/dirwhere other mirrors would redirect.docs/reference.mdsection 7 has why there are two. - Read-back checks. After the engine's sample,
smoke-mirrorreads one redrawn page back under both keys, compares one HTML file byte for byte with the bucket's copy (the canary for Cloudflare's HTML rewriters), and asks for/timestampas a Perl client. - Its row of the run summary, and an
offlinecheck overfixtures/.
Everything else, from the list diff and the batching to the signature checks, the state file and the daily reconcile, is the engine's and is documented once in lib's README.
Fork katoptra/ctan. Two lines of Taskfile.yml are
yours to change: HOST, your domain, and BUCKET, your bucket's name. SOURCE stays:
CTAN asks that a mirror pull from its master.
The bucket is the mirror. Every CTAN path sits at its root, under CTAN's own name, plus
one reserved prefix, .state/, for the listing the last run left behind. Storage is the
whole bill: 140 GB at R2's $0.015 per GB-month, under $2.
| What | Why |
|---|---|
| An R2 bucket, or any S3-compatible bucket | Objects and their state |
| An API token with Object Read & Write, scoped to that bucket | The three AWS_* values in step 3 |
A custom domain on the bucket, which is HOST |
What clients and the read-back checks fetch from |
aws.config keeps every upload under 4 GiB a single PutObject and sends the five larger
CTAN files in 512 MiB parts. How the engine uses a bucket, what .state/ holds and why
the state is only a cache of the bucket: lib, Storage.
Four values, in one vault item named ctan:
| Section | Field | What it is | Reaches the run as |
|---|---|---|---|
r2 |
access_key_id |
The token from step 2 | AWS_ACCESS_KEY_ID |
r2 |
secret_access_key |
Its secret | AWS_SECRET_ACCESS_KEY |
r2 |
endpoint |
https://<account-id>.r2.cloudflarestorage.com |
AWS_ENDPOINT_URL |
healthcheck |
url |
Optional: a healthchecks.io ping URL | HEALTHCHECK_URL |
Put your vault's UUID into the four references in op.env, make a service
account that can read that vault, and store its token as the OP_SERVICE_ACCOUNT_TOKEN
secret, on the organization or on the repository. That is the whole requirement; nothing
else is configured on GitHub. Finding a vault's UUID, why a UUID and not a name, and the
repository-secrets alternative: lib, Secrets.
Four Cloudflare rules, set by hand once and never touched by the pipeline, all scoped to
the mirror's hostname. docs/reference.md section 6 has each rule's expression and the
measurement behind it.
| Rule | What it does |
|---|---|
| Configuration Rule | Turns off Email Obfuscation, Rocket Loader, Automatic HTTPS Rewrites and Browser Integrity Check. The first three rewrite HTML in flight; a mirror that alters bytes is not a mirror. The fourth refuses Perl and Python clients that every other CTAN mirror serves. |
| Cache Rule | Bypass. Caching saves nothing under 10M requests a month and would leave /timestamp stale. |
| Transform Rule | / serves CTAN's index.html |
| Transform Rule | /dir/ serves that directory's page |
On a laptop with go-task, the 1Password CLI and Docker or Apple container:
task check # render every command of the pipeline inside the image; diff against render.txt
task run -- task offline # the read-back checks and the directory pages, over a canned hour; no network
task plan # the read-only half against your bucket: list, state, diff, split; nothing uploadedThen Actions, sync, Run workflow. The first run finds an empty bucket, takes the whole tree as the delta and works it four batches at a time, queueing the next run itself until nothing is left: about 140 GB from CTAN over several chained runs. Pause the healthcheck first. Every run after that moves the hour's delta, usually a few dozen files.
Nothing in this repository schedules a run. Add a schedule: trigger to
.github/workflows/sync.yml with a minute of your own, or dispatch it from outside as
this mirror is. CTAN asks for once an hour at a fixed minute.
task alone prints the menu. A run takes its flags after the double dash, and the same
flags go in the workflow's vars input:
task sync # one run, the same thing Actions runs
task sync -- MAX_BATCHES=8 # more of a backlog in one run
task sync -- RECONCILE=true # rebuild the state from the bucket and sweep orphans now
gh workflow run sync.yml -f vars='RECONCILE=true'Every run appends one table to its job page: when it started and how long it took, the delta and what landed, the state, storage, the signature check, and this mirror's row of directory pages redrawn. A failed run is the only alert: healthchecks.io emails when the hour passes without a ping.
Failed runs, first fills, rebuilding the state, deleting a key, redrawing every page,
rotating the token, a dispatcher that stopped: docs/reference.md
section 5, the runbook.
docs/reference.md holds the numbers behind the mirror, each with
the date it was verified:
- Baseline: the measured tree, its churn, its busiest hours
- Limits: R2, the Cloudflare zone, Actions, CTAN's master, the tools
- Cost: the bill line by line, and what traffic at any share of CTAN would cost
- Monitoring: the healthcheck and its settings
- Runbook
- Zone configuration: every rule with its expression
- Why directory pages: listings drawn under two keys
Pull requests are welcome.
MIT licensed. Built by Josh Vaughen.