Upload Image
Click to upload your chest X-ray image
Select from your computerOr try a sample image:
Prediction Results
Processing image...
A production MLOps case study: eight PyTorch models trained on public research data, served behind a containerised FastAPI service, deployed on a single Hetzner VPS with auto-HTTPS, CI/CD, and live observability.
This page is one project under AnichLabs OÜ, an Estonian software and AI company. The demo you can interact with here is the production engineering layer built around a chest X-ray classifier trained on public research datasets.
Four CNN architectures (MobileNetV2, EfficientNet-B0, ResNet-50, and a custom Victorio network) are each trained in two normalisation domains (CheXpert and ImageNet), giving eight checkpoints that ship inside one FastAPI container. The Demo section lets you upload an X-ray or pick a sample and see all relevant metadata, including model confidence and per-class probabilities. The Results section shows test-set accuracy across all eight models, and the Deployment section surfaces live operational metrics straight from the running container.
Everything here runs on a single self-managed server: the container, the reverse proxy, the auto-renewing TLS certificate, and the live dashboard. The aim was a small, reliable, fully owned production system rather than a managed-platform demo.
Click to upload your chest X-ray image
Select from your computerProcessing image...
Every request follows the same path from the public internet down to the model and back.
A single VPS hosts everything: TLS terminator, rootless container, model inference. systemd brings the container back at boot (linger enabled), GitHub Actions deploys on every push to main, Caddy renews the Let's Encrypt certificate automatically.
Each layer is a deliberate, well-documented choice.
| Layer | Choice | Why |
|---|---|---|
| Server | Hetzner CX23 | Small x86 VPS (2 vCPU, 4 GB RAM) that comfortably hosts PyTorch CPU inference. EU jurisdiction. |
| OS | Ubuntu 24.04 LTS | Stable, well-documented, Podman 4.9 in repos. |
| Runtime | Podman rootless | No daemon, smaller blast radius. K8s migration is one command away. |
| Compose | podman compose (Compose v2 plugin) | Same YAML works locally and on the server. |
| Reverse proxy | Caddy 2.11 (host) | Zero-config HTTPS, HTTP/3 by default. |
| TLS | Let's Encrypt via Caddy ACME | Auto-renewed every 60 days. |
| Supervision | systemd user unit + linger | Standard, observable, survives reboots. |
| CI/CD | GitHub Actions | push -> rebuild -> healthcheck. |
| Observability | Prometheus inside the app + a small JSON endpoint | Live dashboard without a full Grafana stack. |
| Firewall | UFW default-deny + fail2ban | Only 22 / 80 / 443 exposed. |
Folders relevant to deployment. Model checkpoints and offline wheels are gitignored and transferred out-of-band.
cxr-cnn-k8s-benchmark/ ├─ podman-compose.yml # base compose, shared ├─ deployment/ │ ├─ compose.dev.yml # dev override (public port, --reload) │ ├─ compose.prod.yml # prod override (localhost-only, healthcheck) │ └─ api/ │ ├─ Containerfile.cpu # CPU image recipe │ ├─ requirements-cpu.txt │ ├─ wheels-cpu/ # offline pip wheels (gitignored) │ ├─ app/ # FastAPI source │ ├─ config/ # JSON: models, preprocessing, evaluation │ └─ demo/ # the page you are looking at ├─ experiments/ │ ├─ checkpoints/ # 8 .pt files (gitignored) │ └─ results/ # training and evaluation outputs ├─ reports/ # paper figures and tables ├─ .github/workflows/deploy.yml # CI/CD pipeline ├─ DEPLOY.md # operational reference └─ README.md
The design optimises for three things in order: reliability, cost, and portfolio clarity.
Reliability comes from standard, well-understood tools. systemd, Caddy, Podman, UFW, fail2ban: all of them are thoroughly documented and behave predictably when something goes wrong. None of it depends on community chat channels for support.
Cost is held down by running a single, small VPS with the API container bound to localhost. The reverse proxy is the only thing the public internet can reach, and it handles TLS, HTTP/3, gzip, and the access log in one binary.
Portfolio clarity drives the decision not to deploy Grafana, Prometheus servers, or a full K8s cluster for one demo. They would be overkill, expensive, and would obscure the engineering behind layers of infrastructure no one would inspect. Instead, the app instruments itself, exposes its metrics, and the page you are reading renders them client-side. Same signal at one-tenth the operational weight.
The same pattern repeats for every other portfolio app on this server. Add a subdomain in DNS, add a one-line block in the Caddyfile, deploy another rootless container behind it. Multi-tenancy without Kubernetes.
Test-set accuracy of all eight trained models. From the production evaluation pipeline.
Loading…
Updated in real time from this server, every 5 seconds.