Putting three old projects back online with one Dockerfile
A roguelike, a pathfinding visualiser and FairSight, all Streamlit apps, now on my homelab under one deployment pattern. Plus a bug that only exists once a game is public.
On this page
Before the homelab had to survive three months without me, I wanted my older projects back on the web, linked from my portfolio, instead of sitting in repos nobody runs. Three of them were Streamlit apps:
| Project | From | Now at |
|---|---|---|
| A turn-based roguelike | FIT1045 | roguelike.vrp2206.com |
| A 2D pathfinding visualiser | FIT1045 | pathfinding.vrp2206.com |
| FairSight, a hiring-bias dashboard | FIT1055 group project | prototype.fairsight.org |
Three similar apps is the point where copy-pasting a setup three times starts to hurt. So they share one pattern.
One folder per app
~/Projects/
├── proxy/ Caddy + the main tunnel, one block per hostname
├── roguelike/
│ ├── src/ the app's git checkout (branch main)
│ ├── Dockerfile
│ ├── docker-compose.yml
│ └── .env this app's tunnel token, readable only by me
├── pathfinding/ same layout
└── fairsight/ same layout
The app's own repo lives untouched in src/. Everything about running it lives one level up. That keeps deployment files out of coursework repos, and redeploying after a push is one line:
git -C src pull && docker compose up -d --build
The shared Dockerfile
All three use the same Dockerfile. Only two things change per app: which packages to install and which file to run.
FROM python:3.13-slim
ARG PIP_INSTALL
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir $PIP_INSTALL \
&& useradd --create-home app && chown -R app:app /app
USER app
ENV APP_FILE=app.py
EXPOSE 8501
HEALTHCHECK \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health')"
CMD ["sh", "-c", "exec streamlit run \"$APP_FILE\" --server.headless=true \
--server.address=0.0.0.0 --server.port=8501 --server.fileWatcherType=none \
--browser.gatherUsageStats=false --client.toolbarMode=viewer"]
The choices that matter:
- A non-root user. If someone finds a way to run code through the app, they get an unprivileged account inside a container, not root.
- A health check against Streamlit's own
/_stcore/healthendpoint, using Python's standard library, so the slim image doesn't needcurl. Docker now knows the difference between "running" and "working". - Pinned versions where the repo had no
requirements.txt, passed in as a build argument (streamlit==1.40.2, andplotly/numpyfor the pathfinder). An old app shouldn't break because a library released a new major version. - Production flags for Streamlit: no file watcher (nothing is being edited in the container), no usage statistics, and a viewer-only toolbar so visitors don't get developer options.
Each docker-compose.yml then adds a 512 MB memory limit, so one misbehaving app can't starve everything else on the box, and publishes the app on loopback only:
ports:
- "127.0.0.1:8501:8501" # 8502 and 8503 for the other two
Routing: Caddy, on loopback only
Public traffic arrives the same way as for ShipDoc: Cloudflare Tunnel → Caddy → app. The new apps are three more blocks:
http://roguelike.vrp2206.com {
reverse_proxy localhost:8501
}
http://pathfinding.vrp2206.com {
reverse_proxy localhost:8502
}
http://prototype.fairsight.org {
reverse_proxy localhost:8503
}
Caddy got one more change at the same time. It runs with network_mode: host, and by default it listened on port 80 on every interface, so anything on my LAN could reach every site directly, around the tunnel. One global option fixes that:
{
auto_https off
# Loopback only: cloudflared (host network) is the sole way in
default_bind 127.0.0.1 [::1]
}
Now the tunnel is the only way in. Both the old Caddyfile and compose file were copied to dated .bak-2026-10-23 files before any edit, so rolling back is a copy.
Each app's compose file also runs its own cloudflared connector, with its own token and its own metrics port (20242–20244). An app and its tunnel live in the same folder, so they're started, stopped and deleted together.
A bug that only exists once it's public
The roguelike was written to be played by one person on one laptop. It has a persistent save: after every room, the whole game state is written to advanced_game_data.json, and a new game session loads that file on startup.
On a laptop, that's a save game. On a public server, everyone shares one save file. A new visitor doesn't start a new run: they pick up wherever the last visitor left off. The repo even ships with a save in it, a Tank in room 5 with exactly this much health:
"health": 9.900000000000002
(Damage is scaled by armour, attack * (1 - defense), so a Tank takes hits like 0.85. Subtract enough of those and floating-point rounding shows up, because numbers like 0.85 and 9.9 can't be stored exactly in binary. The fix there is just rounding for display.)
The proper fix is to keep game state in Streamlit's per-browser session_state and either drop the file save or give each visitor their own save. Until then, the game works, but it's multiplayer in a way I never designed.
Why bother with old projects?
These were coursework, and they're not what I'd write today. But a link on a portfolio that actually opens is worth more than a screenshot. And as the next two posts show, rereading old code with fresh eyes turns up things I couldn't see when I wrote it: a pathfinder that isn't quite what its README says, and a bias dashboard whose bias was bigger than I'd claimed.