Serving Game Content at Speed with FastDL
If you have ever joined a modded Counter-Strike or Garry's Mod server and watched a download bar fill before you spawned, you have used FastDL without knowing it. The game server does not send that custom content itself, it points your client at a plain HTTP server and lets it grab the files there, which is much faster. FastDL is my take on that HTTP server: a small, fast content host written in Rust on axum and Tokio, with proper range support and a real access control system. The source is at github.com/Alyx-Network/FastDL.
What FastDL is actually for
Source engine games let a server offer custom content, maps, models, materials, sounds, but downloading all of that through the game protocol is slow and hurts the server. The fix, built into the engine, is FastDL: the server sets a download URL, and clients fetch the missing files over HTTP from there instead. Your job as the host is to run a web server that hands out those files correctly.
"Correctly" hides a few sharp edges, which is why a purpose-built server beats pointing nginx at a folder and hoping. The engine expects byte ranges so downloads can resume, it expects compressed files to arrive untouched so it can decompress them itself, and in the real world you want to stop random scrapers and abusers from hammering your bandwidth. FastDL handles all three.
How it serves files
The mental model is dead simple: files live under a storage/ directory and map one to one onto the request path. A request for a compressed map resolves straight to the file on disk.
http://host:3000/maps/de_dust2.bsp.bz2 -> storage/maps/de_dust2.bsp.bz2Files are sent raw, with Accept-Ranges: bytes and full Range support, so an interrupted download picks up where it left off instead of starting over. The compression handling is the detail that matters most. A .bz2 or .gz file is delivered as-is, with no Content-Encoding header, because the Source engine wants to receive the compressed bytes and decompress them itself. Send the wrong header and the browser or client decompresses early, and the engine chokes on the result. FastDL does the correct, slightly counterintuitive thing on purpose.
There is one more convenience: if an exact file is missing, the server transparently falls back to a .bz2 and then a .gz variant. So a request for a plain map still succeeds if only the compressed version is on disk, which matches how people actually store content.
Running it
Locally it is a single Rust binary. Build it, run it, and drop your content into storage/, which is created for you on first run.
cargo build --release
./target/release/fastdlFor a real deployment there is a multi-stage Docker setup. Compose builds the image, exposes port 3000, and mounts your content and config so both survive restarts.
docker compose up -d --buildThat mounts ./storage to /app/storage as a persistent content volume and ./config.yaml to /app/config.yaml read-only. There is also a nixpacks.toml for platforms like Railway or Coolify, where you mount a persistent volume at storage/ and set the port. The listen port itself is resolved in a clear order: the PORT environment variable first, then global.port in the config, then a default of 3000.
Configuration
Configuration is a single config.yaml that is hot-reloaded on change, so you can tune rules on a live server without a restart. The smallest useful config is just a port.
global:
port: 3000Directory listing is off by default and opt-in per path, because you rarely want your entire content tree browsable. You enable it and then whitelist the directories that may be listed.
directory_listing:
enabled: true
allowed_paths:
- "/"
- "/public"Setting enabled: false disables listing everywhere. Leaving allowed_paths empty while enabled allows all directories. And a path like /public allows that directory and every subdirectory beneath it.
Access control rules
The part I care about most is the rule engine, because a public FastDL host is a bandwidth target. Rules are evaluated in order, and the first rule whose conditions match and whose action is decisive wins. A rule pairs a set of conditions with an action, and can carry a custom message and status.
rules:
- name: "admin-area-access"
conditions:
- field: path
operator: starts_with
value: "/admin"
action: access
message: "Authentication required"
status: 401
access:
bearer:
- "change-me-secret-token"Conditions in the top-level list are combined with AND, so all of them must match. Any entry can instead be a nested or or and group, which is enough to express fairly rich policies without a scripting language.
conditions:
- or:
- { field: user_agent, operator: contains, value: "bot" }
- { field: user_agent, operator: contains, value: "crawler" }
- and:
- { field: path, operator: starts_with, value: "/api" }
- { field: method, operator: equals, value: "GET" }The fields you can match on cover the request surface: path, user_agent, method, ext, header:NAME, and two different notions of the client address. The operators are the usual set, equals, not_equals, starts_with, ends_with, contains, matches for regex, in and not_in, and exists and not_exists, all case-insensitive.
The two IP fields are worth understanding, because mixing them up is a real security bug. client_ip is the visitor IP derived from headers like cf-connecting-ip, x-real-ip and x-forwarded-for. Those headers are spoofable, so you should only trust them when a proxy you control sets them. peer_ip is the raw TCP connection source, which cannot be forged by a header, and is the one to use when you need to prove where a connection physically came from. For both, the IP operators accept CIDR ranges as well as plain addresses.
That distinction powers the single most useful rule the project ships: lock the whole server so it only answers connections that actually arrive through Cloudflare, checked against Cloudflare's published address ranges by peer IP.
- name: "cloudflare-only"
conditions:
- field: peer_ip
operator: not_in
value: ["173.245.48.0/20", "104.16.0.0/13"]
action: deny
status: 403With that in place, anyone hitting the origin directly is refused, and all real traffic is forced through your CDN where caching and DDoS protection live. It is a small rule with an outsized effect on a public content host.
Conclusion
FastDL is a narrow tool that does one job well: hand Source engine clients their custom content quickly and correctly, with resumable ranges, honest compression handling, and enough access control to survive being public. It is a single Rust binary, configured by one hot-reloaded YAML file, deployable by Docker or nixpacks in a minute.
It is the same instinct behind most of the infrastructure I build. A general-purpose web server can technically serve these files, but a purpose-built one that understands the protocol's quirks and ships with a real rule engine turns "technically works" into "works, and does not fall over." Everything is open at github.com/Alyx-Network/FastDL.
Thanks for reading. Questions or disagreements? Email me.





