This file gives the numbers of the mirror:
- The dimensions of the tree
- The limits of the platforms
- The cost
- How we monitor the mirror
- The steps to do when a run stops with an error.
Each number has the date of its check. A number can become incorrect after that date. Before you use a number, measure it again if necessary. The commands that gave the measured numbers are adjacent to them.
In this file, a GB is 10^9 bytes, and GiB and MiB are binary units. R2 gives its limits in
binary units, and CTAN gives its sizes in decimal units. The "stored set" is each regular file
that rsync -rL gives, and normalise keeps these files.
The numbers in this table are from a dereferenced listing of dante on 2026-08-26 (538,289 lines, 6.9 s of wall-clock time, 50.7 MB).
| Quantity | Value |
|---|---|
| Stored set | 511,027 objects, 139.63 GB (130.04 GiB) |
| Revision-stamped tlnet and tlcontrib containers, in the stored set | 15,133 objects, 7.09 GB. Each has the same bytes as the file with its stable name. |
| Largest object | 6,865,013,189 B (systems/mac/mactex/MacTeX.pkg) |
| Longest key | 151 bytes |
| Directories that contain a file, and not only subdirectories | 24,953 (24,952, and the root) |
| All directories, with the directories that contain no file | 27,262: the 24,953, and 2,309 that contain only subdirectories. pages makes a page for each of them (2026-08-27). |
| Objects of the directory pages | 54,523: the page of each directory at its two keys. The page of the root has no slashless key. |
| Changed files, in the last 30 days | 16,574 files, 4.72 GB. The average is 23.0 files each hour. |
| Hours with a change, in the last 30 days | 284 of 720 |
| Hours with more than 1,000 files, in the last 30 days | 3 |
| The hour with the most files, in the last 365 days | 5,950 files, 0.037 GB (2026-01-12 16h) |
| The hour with the most bytes, in the last 365 days | 20.35 GB in 9 files (2026-03-01 09h, copies of the ISO images) |
| The hour with the most bytes, with no installers | Less than 0.001 GB |
| Objects larger than the single-part limit of R2, 4.995 GiB | 5: two MacTeX pkgs at 6.87 GB, and three ISOs at 6.78 GB |
| Objects larger than the cache limit of Cloudflare, 512 MB | 7: the five in the previous row, and two protext zips at 1.14 GB |
The full tree at BATCH_GB=4 |
28 batches, and 5 files that are each larger than a batch, each in a batch of one file: 33 rsync connections. The largest batch has 98,141 files. |
The number of changed files is a minimum. A file that changed two times in a month counts one time, because we calculate the number from the mtimes in the listing. At three times the measured rate, the number stays less than 100k PutObjects a month. That is much less than the free tier of the account (lib, R2 specifics).
Sources: R2 limits, S3 API compatibility, Multipart. The date of the check is 2026-08-26.
| Limit | Value | What the mirror uses | At the limit |
|---|---|---|---|
| Objects, and storage for each bucket | No limit | 511k, 140 GB | Cost only |
| Object size | 5 TiB (4.995 TiB in operation) | 6.87 GB, maximum | R2 rejects it |
| Single-part upload | 4.995 GiB = 5,363,466,240 B | 5 objects are larger. Thus, the run uploads them as multipart uploads. | EntityTooLarge, and the run stops with an error |
| Multipart parts | 10,000, each 5 MiB–5 GiB, all of one size | 13 parts for each 6.87 GB file, at 512 MiB | R2 rejects it |
| Multipart uploads that are not complete | R2 aborts them after 7 days | A maximum of one, after a job that the runner stopped | R2 keeps their storage for a maximum of 7 days |
| Key length | 1,024 bytes | 151, maximum | R2 rejects it |
| PutObject calls for each key | 1 each second | The run uploads the state one time for each batch, at intervals of minutes | 429 |
ListObjectsV2 page |
1,000 keys | 497 pages for each reconcile | — |
DeleteObjects batch |
1,000 keys | One call in a typical hour | MalformedXML |
| Class A operations | 1M a month at no cost, then $4.50 for each million | Approximately 30 each hour | Cost |
| Class B operations | 10M a month at no cost, then $0.36 for each million | 1 for each run, and the traffic of clients | Cost |
| Custom domains for each bucket | 100 | 1 | — |
The rsync image of lib has an aws.config at /etc/aws.config, and it sets
AWS_CONFIG_FILE to that path. This file sets multipart_threshold = 4GB, which the AWS CLI
reads as binary: 4 GiB. That value is safely less than the single-part limit of 4.995 GiB.
The file also sets multipart_chunksize = 512MB, which gives 13 parts of the same size for
the largest file.
The pipeline sends no request to the Cloudflare API. Section 6 gives the rules that the mirror uses, and the cause for each rule.
| Limit | Value | What the mirror uses |
|---|---|---|
| Cache Rules | 10 | 1 |
| Transform Rules | 10, with no regex on the Free plan | 2 |
| Configuration Rules | 10 | 1 |
| Single Redirects | 10 | 0 |
| Maximum object size for the cache | 512 MB | 7 objects are larger. R2 serves them for each request. |
| Upload through the zone | 100 MB | 0. The run uploads to the S3 endpoint. |
| Limit | Value | What the mirror uses |
|---|---|---|
| Job time | 6 h, from the start of the job | timeout-minutes: 355 |
| Runner disk | 14 GB, from the documentation | The toolbox image (1.2 GB), and a maximum of one 4 GB batch and the 6.87 GB file |
| Runner RAM | 16 GB | Less than 300 MB |
| Jobs at the same time | 20 on the Free plan | 1. With concurrency: sync, a run for the next slot waits until the run before it stops. |
| Job summary | 1 MiB for each step, 20 steps | Approximately 2 KB |
| Runs that wait | One run waits for each concurrency group. GitHub cancels the other runs. |
A run that the scheduler starts during a run waits. The delta of the next hour includes the work of a run that GitHub cancels. |
The documentation gives no limit for the quantity of the log. GitHub removes log lines, and
it does not tell you. Thus, report counts from .run/, and not from the log.
No cache keeps the toolbox image, ghcr.io/katoptra/toolbox:rsync-v2, between jobs. Each job
pulls it from GHCR one time, before the pipeline. Each task run of that job then uses that
image. lib, The toolbox tells how the image
verb does this. The README of lib also gives the cause for no cache of the image between
jobs.
A run pulls the toolbox image from ghcr.io with no login, and it uses no other registry. If
GHCR rejects the request, or the run cannot pull the image, the run stops at task image,
before the pipeline starts. Docker Hub lets each IP address with no login pull an image 100
times an hour. Only katoptra/lib uses Docker Hub: it pulls the pinned Ubuntu base from there
when it builds a release. A run does not use Docker Hub.
| Limit | Value |
|---|---|
| Syncs that CTAN tells a mirror to do | One or more each hour, at the same minute |
| The limit of mirmon | 28 h. After this limit, the CTAN redirector does not send clients to the mirror. |
dante max connections |
Not published. A run opens a maximum of 2, one after the other. |
| Full listing | 6.9 s, 50.7 MB |
rsync MAXPATHLEN |
1,024 bytes |
rsync --max-alloc |
Approximately 1 GB for each allocation. The file list is approximately 10 MB. |
The retry task tries again after these rsync exit codes: 5, 10, 12, 30 and 35. Exit 24 is not
an error: upstream deleted a file during the transfer, and the next run gets the change.
| Tool | Value |
|---|---|
| AWS CLI retries | retry_mode = standard, max_attempts = 10. The maximum backoff is 20 s. |
| AWS CLI timeouts | Connect 60 s, read 300 s, from the configuration |
AWS CLI max_queue_size |
1,000 tasks. When the queue is full, the CLI waits, and it does not stop with an error. |
xz memory |
100 MB at -6, 421 MB at -9 |
sort memory |
68 MB for 511k lines |
shasum -a 512 |
Approximately 650 MB/s. Thus, the check of a 4 GB batch continues for approximately 7 s. |
split suffixes |
-a 4 is necessary for more than 676 files |
The Hobbyist plan has no cost. It gives:
- 20 checks
- 100 log entries for each check
- 5 pings each minute for each check
- A maximum of 100 kB for the body of each ping.
The pipeline sends one ping for each run. Thus, the log contains the pings of approximately the last four days.
The costs are from R2 pricing, on 2026-08-26, the date of the check:
- Storage: $0.015 for each GB-month, with 10 GB-month at no cost
- Class A operations: $4.50 for each million, with 1M at no cost
- Class B operations: $0.36 for each million, with 10M at no cost
- Egress: no cost.
DeleteObject and AbortMultipartUpload have no cost. Cloudflare increases the quantity that
you use to the next full unit, and the unit for operations is one million. Thus, if the Class A
operations are one operation more than the free tier, the cost is $4.50.
The free tier is for each account, not for each bucket (lib, R2 specifics). The storage costs in this section are gross: they do not subtract the free tier. In the costs of operations, this bucket gets all of the free tier. Thus, these costs are a minimum.
| Item | Value |
|---|---|
| Storage | 139.8 GB-month (0.14 of it is directory pages, each at two keys) × $0.015 = $2.10 a month |
| Storage at the 200 GB ceiling | 200 GB-month × $0.015 = $3.00 a month |
| Class A operations each month | Approximately 45k, with no cost. They are 30 reconcile listings, 1,440 PutObject calls for the state, the changed files and some thousand directory pages. A run that makes all the pages again makes 54.5k operations. |
| Class B operations each month | 720 GET calls for the state, and approximately 5,760 GET calls from smoke: approximately 6.5k, thus $0 |
One scheme-full installation with no cache |
11,919 GETs, 5.51 GB. No cost until 27 installations a day. |
| Budget | $5 a month. split rejects a tree larger than CEILING_GB (200) before the run uploads a file. |
At this size, storage is the only item with a cost. This mirror keeps the Class A operations low: only the daily reconcile gets a listing of the bucket. If an hourly sync gets a listing of the bucket two times in each run, the cost at 200 GB is $4.50 a month.
We do not know if the storage meter of R2 counts a GB as 10^9 or as 2^30 bytes. This file uses 10^9, which gives the larger cost. If the meter is binary, the same bucket is 130.2 GiB-month, $1.95.
The numbers in the previous table are the costs of the pipeline. The costs of the traffic from
clients are from the request logs of dotsrc.org, measured on
2026-08-29. These logs record each HTTP request to mirrors.dotsrc.org, with its path and
size, and dotsrc.org publishes them as daily JSON. The measurement uses the CTAN tree of
dotsrc.org on five sample days:
- 2021-11-16
- 2022-01-19
- 2022-02-16
- 2022-03-05
- 2022-03-09.
| Quantity | Value |
|---|---|
| One mirror with moderate traffic | 68,080 requests and 85 GB a day: 2.1M requests and 2.6 TB a month |
| Range on the five days | 46,770–109,710 requests, 31.4–118.0 GB |
| Average object that the mirror serves | 1.25 MB |
| All of CTAN, on approximately 100 mirrors | Approximately 150M requests and 200 TB a month |
The line for all of CTAN is an estimate, not a measurement. It gives dotsrc 1.4% of the traffic of the archive. It gives the primary nodes 3% of the bytes, and Wikipedia gives more than 6 TB a month for them. The estimate can be two times too high or two times too low.
Egress has no cost. Thus, the bytes have no cost, and only the number of requests has a cost. Each GET is one Class B operation. The first 10M a month have no cost. After that, the cost of each million is $0.36, and Cloudflare increases the quantity that you use to the next full million.
The table that follows gives the cost of this bucket for a part of the CTAN traffic, with the cache bypassed. Each total includes the storage, $2.10.
| Part of the CTAN traffic | Requests a month | Requests a second | TB a month | Class B with a cost | Total a month |
|---|---|---|---|---|---|
| 1% | 1.5M | 0.6 | 2 | 0 | $2.10 |
| 3% | 4.5M | 1.7 | 6 | 0 | $2.10 |
| 5% | 7.5M | 2.9 | 10 | 0 | $2.10 |
| 8% | 12.0M | 4.6 | 16 | 2M | $2.82 |
| 10% | 15.0M | 5.7 | 20 | 5M | $3.90 |
| 15% | 22.5M | 8.6 | 30 | 13M | $6.78 |
| 20% | 30.0M | 11.4 | 40 | 20M | $9.30 |
| 50% | 75.0M | 28.5 | 100 | 65M | $25.50 |
| 75% | 112.5M | 42.8 | 150 | 103M | $39.18 |
| 100% | 150.0M | 57.1 | 200 | 140M | $52.50 |
A mirror that mirror.ctan.org selects for clients is near the 1% row, and a small quantity
more.
The free tier stops at 10M requests a month: 6.7% of the archive, and five times the traffic
of dotsrc. After that, the rate does not change.
The cost to serve all the requests of CTAN (200 TB) is $52.50 a month. Of this cost, $2.10 is for storage, and the remaining cost is for operations. On S3, the cost of the egress of the same 200 TB is approximately $18,000.
The zone bypasses the cache (section 6). Cache storage and purges have no cost. Thus, a cache decreases only the Class B operations, and only the hit ratio is important. On 2026-08-29, we made a model of the cache for the tree of 511,027 objects. The model uses these items:
- The requests for the objects agree with Zipf's law.
- Ten caches in front of the origin. Cloudflare caches in each datacenter. Tiered Cache, at no cost on all plans, puts a second group of caches, one for each region, in front of the origin.
- Poisson arrivals for each object, each cache and each TTL window. In a window with one or more requests, the cache fills one time from the origin.
The primary condition of the model is α=0.9 with ten caches. The range of the model is from α=0.7 with thirty caches to α=1.1 with ten.
| Part | Bypass | 24 h TTL | 48 h TTL | 1 week TTL |
|---|---|---|---|---|
| 1% | $2.10 | $2.10 (36% hit) | $2.10 (40%) | $2.10 (49%) |
| 5% | $2.10 | $2.10 (47%) | $2.10 (52%) | $2.10 (62%) |
| 10% | $3.90 | $2.10 (52%) | $2.10 (57%) | $2.10 (68%) |
| 20% | $9.30 | $3.18 (57%) | $2.82 (63%) | $2.10 (74%) |
| 50% | $25.50 | $8.22 (65%) | $6.42 (71%) | $3.54 (83%) |
| 100% | $52.50 | $14.34 (71%) | $10.74 (78%) | $4.98 (88%) |
At 100%, the cost with a 24-hour TTL is from $4.98 to $32.70 in the range of the model. At 50%, it is from $2.46 to $17.94. At 10% and less, it is not more than $3.18, because the free tier is sufficient for all of the range.
With less than 10M requests a month, a cache does not decrease the cost, with all TTLs. The free tier is sufficient for all these requests. At 8% of CTAN, a cache first decreases the cost: the cost is $0.72 a month less. At 20%, the cost is $6.12 less. The cost is $38 less only with all the requests of the archive.
A TTL of one hour does not decrease the cost, with all quantities of traffic. In one datacenter, in one hour, no object gets sufficient requests. The seven objects larger than the 512 MB cache limit of Cloudflare are a miss for each request, with all TTLs.
The cost of a cache is not in dollars. A TTL of more than one hour with no purge has a cost.
During the full TTL, the cache serves previous copies of /timestamp and of the directory
pages. At 24 hours, this is most of the 28-hour limit of mirmon, and at one week it is more
than the limit. Thus, an hourly mirror cannot use the 48-hour and one-week columns.
A purge is the alternative, but it adds these items to the sync path:
- A Cloudflare API token (a sixth secret)
api.cloudflare.com(a fifth endpoint)- 30 URLs for each call on the Free plan. The hour with the most changes in the last year gives approximately 200 calls, and the directory pages of that hour add more.
At the load of this mirror, a purge does not decrease the cost.
The mirror has one healthchecks.io check. A run sends one ping to it, at the end of a run that has no error. A failed run is the only alert.
| Configuration | Value |
|---|---|
| Schedule | a cron expression with the same times as the slots of the mirror, time zone UTC |
| Grace | 3 h |
| Ping from | ping, the last verb of pipeline |
| Set in | HEALTHCHECK_URL, the ping URL of the check, in the healthcheck section of the vault item |
The three AWS_* values are the only necessary values. HEALTHCHECK_URL is the fourth value,
and the only optional one. Without it, the run does not do ping, and the mirror has no
alert.
A run with a full delta of MAX_BATCHES batches continues for a maximum of approximately 75
minutes. The grace of 3 h is sufficient for these items:
- A run that waits for a previous run
- A full run after it
- The retries of curl.
If the mirror stops, the alert occurs between 3 h and 4 h 40 min after the last run with no error. That is much less than the 28-hour limit of mirmon.
lib, Monitoring tells you:
- The cause for a grace that is sufficient for a run that waits
- The cause for a cron check
- How the check also monitors the scheduler.
No step sends /start. Thus, the grace is not a limit for the time of a run. Before the first
fill, or before you start a run for a large backlog, pause the check in the UI of
healthchecks.io. A run of many hours is longer than the grace. A paused check starts again at
its next ping.
The job page of each run is the second location to look. report adds one table to it, from
the files in .run/. The table has these rows:
- When the run started, and the image
- Upstream, the delta and the uploaded files
- The state and the storage
- The signature
- The directory pages
- The upstream clock.
The first item is from the toolbox, and the other items are from the engine. Read this table
first if a run has no error, but you think that its result is incorrect. report gets
the page from GITHUB_STEP_SUMMARY, which is a path on the runner. run gives this
variable to the container, and it mounts the file at the same path. If the variable does not
go into the container, report writes the table to stdout, and the step log shows it. Thus,
a summary in the step log tells you that the variable did not go into the container.
For a command on a laptop, the 1Password CLI must have a login. task sync runs the pipeline
in op run, which resolves op.env and gives each value to the image with its name. The image
sets AWS_CONFIG_FILE. It is safe to run each task again, unless its entry tells you
differently. A second run writes the same bytes, or it writes no bytes.
lib, When a run fails has an entry for each engine verb that can stop a run. The README (Operating it) has two more entries: a clock that does not change, and a run that did not start.
A run stopped with an error.
gh run list --workflow sync.yml --limit 5
gh run view <id> --log-failed | tail -50
curl -sI https://ctan.katoptra.org/timestamp | grep -i -E 'last-modified|cf-cache-status'
aws s3 cp s3://ctan/.state/applied.txt.xz - | xz -d | wc -lThe step that stopped tells the type of problem. Compare the line count of the state with the line count of the listing. The difference tells how many lines the mirror does not have.
Run it again. Use gh workflow run sync.yml or task sync. This is safe: the state is the
state of the last checkpoint, and the run calculates the remaining work again. During the first
fill, this continues the fill.
The run stopped before the pipeline started. If the run stopped at task: [image], the
run could not pull the toolbox image from ghcr.io. The mirror is not the cause. The run
uploaded no file, and it did not change the state. Run it again: no other repair is
necessary.
Make the state again, for a state file that is corrupted or that you think is not correct. If the state file is missing, the next run makes it again.
gh workflow run sync.yml -f vars='RECONCILE=true' # or: task sync -- RECONCILE=trueThe run gets a listing of the bucket and joins it to the listing of upstream. It accepts each
object with the same size as upstream as correct. Thus, if a file changed but kept its size
while the state was not correct, the mirror does not find the change. It finds the change when
upstream changes the file again. This is safe: rebuild uploads no CTAN file.
The bucket is the mirror, and the state is a cache of it. If the state is missing, the cost is one listing. If the bucket is missing, the cost is the first fill in the next entry.
The first fill. There is no flag for it. An empty bucket gives an empty state, and the
delta is the full tree. Each run does MAX_BATCHES batches (four, or more with
-f vars='MAX_BATCHES=8'). Then it starts the next run, until all batches are done. The first
fill makes approximately 511k Class A operations and copies 140 GB from dante, in a chain of
runs.
Pause the healthchecks.io check before the first fill. A run of many hours is longer than the grace.
Delete one key.
aws s3 rm s3://ctan/<key>No purge is necessary: while the cache rule bypasses the cache, the edge contains no copies. If upstream has the key, the daily reconcile finds that it is missing. Then the next hourly run fetches it again. Do not edit the state manually to make the run fetch the key in less time. The risk is too large.
Make all directory pages again. A change to UPSTREAM, to HOST or to the page format
of lib does this at the next run. That run makes the pages of all 27k directories again, at
their two keys. It is approximately twenty minutes long and makes 54.5k Class A operations.
It is safe: the files of the bucket do not change. Do not delete .state/indexed.txt.xz to
do this. Without that file, the run cannot find a directory that CTAN removed, and the page
of that directory stays in the bucket. If you do not change a value, the engine makes a page
again only when its directory changes.
A directory URL gives a 404. The page is at its key, with or without the zone rules:
curl -sI https://ctan.katoptra.org/systems/knuth/katoptra.org.directory.index.html. If
this gives 200 and the directory URL does not give 200, examine the second Transform Rule in
section 6.
It is missing, or its scope is incorrect.
Change a secret. In the 1Password app, edit the r2 section of the item ctan. Then run:
gh workflow run sync.yml && gh run watchAfter a run with no error, delete the previous R2 token in the Cloudflare dashboard. This is safe: the first call with the new credentials only reads.
Abort a multipart upload that did not complete.
aws s3api list-multipart-uploads --bucket ctan --query 'Uploads[].[Key,UploadId,Initiated]' --output text
aws s3api abort-multipart-upload --bucket ctan --key <key> --upload-id <id>This has no cost, and it is safe. R2 aborts it after 7 days.
dante moved. Edit SOURCE in Taskfile.yml. Open a pull request. Then merge it. Until you
merge it, each run tries again after exit 5 for ten minutes, and then stops with an error. This
error is correct, because a person sees it.
The run did not start. sync.yml has one trigger, workflow_dispatch. An external
scheduler, katoptra/dispatch,
dispatches it each hour at :42.
gh run list --workflow sync.yml --limit 3 # createdAt against :42
gh workflow list --all # "disabled_manually" means a manual stopA run that waits for a longer run is usual. One missing hour is also usual, because the next run fetches the delta of that hour. If two hours in sequence have no run, do these steps:
- Read the logs of the scheduler.
- Look for an Actions incident on the status page of GitHub.
- Make sure that the GitHub App of the scheduler is installed on this repository, with
actions: write. - Until the scheduler operates again, start runs with
gh workflow run sync.yml.
A run that you start is the same as a run that the scheduler starts. If you wait, the mirror does all the work subsequently.
The zone has four Cloudflare rules for the mirror. Each rule is applicable only to the hostnames of the mirror, or to the hostnames of the rsync mirrors of katoptra. ctan.katoptra.org and tlnet.katoptra.org are two names of this bucket, and they operate identically. The rules are not part of the pipeline: an operator sets them one time, and the pipeline does not change them. The pipeline sends no request to the Cloudflare API, and it has no credentials for the zone. The four rules do not change frequently, and code that sets them can stop with an error.
This section records the rules. Use it to make the zone again, or to configure the zone of a fork, with no new investigation. A zone usually serves more than the mirror, and a rule that examines only the path is applicable to all of the zone.
| Where | Rule | Expression | What to set |
|---|---|---|---|
| Configuration Rules | The HTML rewriters and the user-agent filter off | (http.host in {<mirror hosts>}) |
Email Obfuscation off, Rocket Loader off, Automatic HTTPS Rewrites off, Browser Integrity Check off |
| Cache Rules | The cache off | (http.host in {<mirror hosts>}) |
Cache eligibility: bypass cache |
| Transform Rules | / serves the index.html of CTAN |
(http.host in {"ctan.katoptra.org" "tlnet.katoptra.org"}) and (http.request.uri.path eq "/") |
Rewrite path to /index.html |
| Transform Rules | Directory URLs serve the page of the mirror | (http.host in {"ctan.katoptra.org" "tlnet.katoptra.org"}) and (ends_with(http.request.uri.path, "/") and http.request.uri.path ne "/") |
Rewrite path, dynamic: concat(http.request.uri.path, "katoptra.org.directory.index.html") |
<mirror hosts> is the hostnames of the rsync mirrors of katoptra, with ctan.katoptra.org
and tlnet.katoptra.org. The rewrites do not use http.host. Thus, the two names of this
bucket get the same key for each URL. The two Transform Rules do not match one URL.
The Configuration Rule is the most important rule. Email Address Obfuscation changes each
text/html response that Cloudflare serves. It adds a script, encodes the mailto: addresses,
and changes the length. Without the rule, an entry of the CTAN Catalogue has 4,006 bytes in the
bucket, and Cloudflare serves it as 4,216 bytes (measured 2026-08-27). Approximately 7,300
files in the tree are HTML, and a mirror that changes them is not a mirror. Rocket Loader is
the same risk, from a second switch: it adds a different script.
Automatic HTTPS Rewrites is the third, and a size check does not find it. Its default
value is on. It changes http:// links in HTML to https://. On 2026-08-28, the domain served
approximately 5% of the 7,229 HTML files of the tree with bytes that the bucket does not
contain. That is approximately 360 files, with +1 byte for each changed link.
On biblio/bibtex/contrib/german/dinat/dinat-index.html, the length did not change. The
rewriter made one link one byte longer. The rewriter operates in an HTML parser, and that
parser removed a newline in the same tag. Thus, the file was 7,688 bytes with and without the
rewriter, and it was different at byte 7,150. As a result, the canary of smoke compares the
object with the response, and not their lengths. The size checks on the sample of keys cannot
find it.
Browser Integrity Check is the fourth: it rejects clients, and it does not change bytes.
Its default value is on. It sends 403 (Cloudflare error 1010) to each client with a
User-Agent that contains one of these strings:
LWPlibwww-perlPython-urllibPycURL.
On 2026-08-28, ftp.fau.de/ctan and ctan.math.illinois.edu served 200 to all four. No
other mirror that we examined rejects them. Thus, with this check on, the mirror serves a
smaller group of clients than the archive that it copies, and this is the only difference.
The check has no effect on these clients:
- Browsers
- curl and wget
- Go and Java
requests.
Thus, a manual check does not find the problem. The check also has no effect on tlmgr.
In TeX Live, TLDownload.pm sets agent => "texlive/lwp", and its sequence of downloaders
is lwp curl wget. Thus, the first downloader that tlmgr uses sends a string that the
filter accepts.
The rule is applicable only to the hostnames of the mirrors. The apex and www of the zone
continue to send 403 to libwww-perl. This shows that the rule did not become wider.
Cloudflare sends no content-length on a text/html response, with these four items on or
off. Thus, smoke gets the size of an object from a ranged GET of one byte, and not from a
HEAD.
The other three rules make the mirror easier to use:
- Without the cache rule, the default cache of the zone operates. With the rule, the zone
gives
DYNAMICincf-cache-status. - Without the first Transform Rule,
/gives a 404. - Without the second Transform Rule, each other directory URL gives a 404.
Do not overwrite a rule for a different hostname in one of these phases. Cloudflare writes a phase as one unit, and a phase that you write removes all other rules in it. Add your rule to the phase.
If you want a cache, section 3 gives the numbers:
- With less than 10M requests a month, the free tier is sufficient for all requests, and a cache does not decrease the cost.
- A TTL of one hour does not decrease the cost, because Cloudflare caches in each datacenter.
- A 24-hour TTL first decreases the cost at 12M requests a month, six times the traffic of a measured mirror. Then the cost is $0.72 a month less.
A cache also makes a purge of each changed key necessary after each batch. The pipeline does not do this purge.
We examined the directory pages on 2026-08-27.
Only the pipeline can make a listing of a directory. R2 serves no directory listings, and it has no index-document configuration. The documentation of Cloudflare gives this text about the domain of a public bucket. The domain does "not let you list the bucket contents at the root of your (sub) domain". This feature request stayed open for years. The rules engine also cannot make a listing: Transform, Redirect, Configuration and Cache rules only change a request or a response that is there.
Snippets are not possible, because they have these limits:
- 5 ms of CPU
- 2 MB of memory
- A package of 32 KB
- No R2 binding.
A Worker with an R2 binding can get a listing of a bucket. But it adds a compute layer in front
of a mirror that has no compute layer. It also adds wrangler to a repository with a list of
tools that does not change.
For CTAN, a listing is usual on a mirror, but CTAN does not make it necessary. Its
instructions for mirror operators tell them to set
Options +Indexes with DirectoryIndex disabled. Thus, the server shows its automatic
listing, and not one of the approximately 111 index.html files in the package directories of
the archive. CTAN makes a smaller number of items necessary:
- HTTPS
- rsync from
rsync.dante.ctan.org - An hourly sync at the same random minute.
All other mirrors have listings. On 2026-08-27, we fetched macros/latex/ from each of the
107 mirrors on mirmon:
- 104 gave a directory listing: Apache autoindex, nginx, the browse template of Caddy, and some themed or JavaScript indexes.
- 3 did not connect.
- No mirror sent a redirect to a different location.
At the root of the archive, most mirrors serve the index.html of CTAN. Thus, here a rule
rewrites / to it, and no rule rewrites other directory URLs to it.
A redirect to ctan.org sends the person away from the mirror. The browse pages of CTAN have
download links to mirrors.ctan.org, which sends the person to a random mirror. Thus, if a
directory URL sends the person there, the mirror does not serve the listing. It also does not
serve the files that the person downloads after it.
A directory URL without its trailing slash must have a second key, because no rule can do
the redirect. All other mirrors send a 301 from /systems/knuth to /systems/knuth/. Apache
can do this, because it knows which names are directories. On 2026-08-28, ftp.fau.de,
mirrors.mit.edu, ctan.math.illinois.edu and mirror.las.iastate.edu all sent this
redirect. mirrors.ctan.org sends a slashless path with no change to the mirror that it
selects. Thus, a person that it sends here gets a 404.
The rules of Cloudflare operate before the origin, and they get only the URL. The URL does not tell which of these paths is a directory:
/systems/knuth a directory
/biblio/biber/base/documentation/Changes a file
13,259 upstream files have no extension (README, Makefile, configure, VERSION,
Changes). A rule that adds a slash to each path with no extension gives a 404 for all of
them. That result is worse than the 27,262 directories that the rule repairs.
The opposite rule is also incorrect: 212 directories have a dot in their name
(biblio/bibtex/utils/bibview-2.0/,
documentation/german/stammtisch/wuppertal/stybesch/pk/300/mag____0.790/). A Worker can try
the 404 again with a slash. But the Worker is in front of each request to the mirror. Also, the
cost of Workers Paid is $5 a month, which is more than the cost of the full bucket.
Thus, pages writes each page at its two keys, <dir>/INDEX and <dir>. The slashless key
gives the listing, and not a redirect. A key contains a page or a CTAN file, not the two,
because upstream is a filesystem: a name is a directory or a file. This has three results:
- The page has a
<base href>. Without it, a relative href on/systems/knuthresolves relative to/systems/. With it, one document is correct at the two URLs. - The slashless copies are in one tree for each depth. No filesystem contains
a/banda/b/at the same time. The same fact makes sure that no upstream file has the slashless key of a page. InSLASH/<depth>/, each page is a file with the same number of components. Thus, no page is the parent of a different page, and each tree uploads as one unit. The maximum depth in the tree is 14. - The run must give the content type of the slashless key. The key has no suffix. Thus,
the run uploads it with
--content-type text/html. Without the type, the CLI sendsbinary/octet-stream, and a browser downloads the page and does not show it.
reconcile can find the first key from its name, but it cannot find the second key from its
name.
Thus, it does not delete a key that is a bare directory of the state. If upstream changes a
directory to a file, the path is not in that set, and reconcile examines it as a usual
file.