CropIntel Production Deployment (single VPS)
One Docker container runs both the Next.js web app and the Python inference service (supervisord manages the two processes). Models are fetched once at container start from a release zip. Right-sized for a single server β no Kubernetes, no Redis, no external model registry.
Architecture
internet ββ Caddy (TLS, :443) ββ Next.js (:3050, public)
β POST /api/predict βββΊ FastAPI inference
β GET /api/health βββΊ service (127.0.0.1:8000,
β never exposed)
ββ models: ml/models/<crop>/<version>/model.tflite
audit log: data/predictions.jsonl
app/api/predict/route.tsvalidates + rate-limits, then forwards the upload to the inference service (ml/serve/inference_app.py), which keeps all crop models loaded in memory (TFLite, ~9 MB per crop).GET /api/healthaggregates web liveness + per-crop model readiness β point the compose healthcheck and your uptime monitor at it.
Prerequisites
- VPS with 2 vCPU / 4 GB RAM (TFLite backend; Keras would need ~4Γ more)
- Docker + compose plugin
- A domain pointed at the VPS (for TLS)
First deploy
git clone <repo> /opt/cropintel && cd /opt/cropintel
# .env β models bundle + optional secrets
cat > .env <<'EOF'
CROPINTEL_MODELS_URL=https://github.com/rakshithj09/CropIntel/releases/download/v1/cropintel-models-mobile.zip
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=...
CROPINTEL_ADMIN_TOKEN=<random string> # protects POST /admin/reload
EOF
docker compose -f docker-compose.prod.yml up -d --build
curl -fsS http://localhost:3050/api/health # expect {"web":"ok","inference":{"ready":true,...}}
The models zip is produced by:
python -m ml.scripts.package_models --tflite-only -o cropintel-models-mobile.zip
and uploaded to a GitHub Release (or any direct-download URL).
Reverse proxy (TLS)
Caddy on the host is the simplest option:
# /etc/caddy/Caddyfile
yourdomain.example {
reverse_proxy 127.0.0.1:3050
}
Caddy sets X-Forwarded-For automatically. The in-memory rate limiter keys on
the client IP β behind any proxy that does NOT set X-Forwarded-For, all
clients share one bucket. Verify your proxy sets it.
Updating
| What changed | Do |
|---|---|
| Code | git pull && docker compose -f docker-compose.prod.yml up -d --build |
| Models (new bundle) | update CROPINTEL_MODELS_URL, then rm ml/models/.cropintel-fetch-ok && docker compose -f docker-compose.prod.yml restart |
| Models (promote a version already on disk) | see below |
Gotcha: ml/models/.cropintel-fetch-ok is a sentinel that suppresses
re-downloading the models bundle on every container start. A new bundle URL is
silently ignored until you delete this file.
Model promotion / rollback
Versions live in ml/models/<crop>/v1_YYYYMMDD_HHMMSS/. The serving version is
pinned by ml/models/<crop>/production.json; without it, the latest complete
version serves (legacy behavior).
# status of every crop (serving version, test + external accuracy)
python -m ml.scripts.promote_model --status
# promote (gated on metrics.json accuracy + a passing external_eval.json)
python -m ml.scripts.promote_model --crop rice --version v1_20260612_103000
# instant rollback to the previous pointer
python -m ml.scripts.promote_model --crop rice --rollback
# apply without restarting the container
curl -X POST -H "X-Admin-Token: $CROPINTEL_ADMIN_TOKEN" localhost:8000/admin/reload
The promotion gate requires an external evaluation (out-of-training-distribution images), produced with:
python -m ml.scripts.test_external --crop rice --path ml/field_test/rice --save-json
Never promote on in-dataset test accuracy alone β the rice and soybean models both scored 100% in-dataset while failing badly on external images (shortcut learning). The honest number is external accuracy.
Monitoring & logs
Uptime: point an external pinger (UptimeRobot / healthchecks.io free tier) at
https://yourdomain.example/api/healthevery minute. An on-box monitor cannot alert you when the box itself dies.Process restarts:
restart: unless-stopped+ supervisord auto-restart handle crashes; the compose healthcheck flags a wedged container.Process logs:
docker compose -f docker-compose.prod.yml logs -f(json-file driver rotates at 20 MB Γ 5 files).Prediction audit log:
data/predictions.jsonlβ one line per request (crop, model version, disease, confidence, entropy, verification status, image quality, latency, image sha256; no image bytes). Use it for drift analysis: a risingnot_in_catalog/unknownrate for a crop means the field distribution is moving away from training.Rotate it with host logrotate β
/etc/logrotate.d/cropintel:/opt/cropintel/data/predictions.jsonl { size 50M rotate 10 copytruncate compress missingok }
Backups
# nightly at 03:00 β models + pointers + audit log, keep 7
0 3 * * * /opt/cropintel/scripts/ops/backup.sh /opt/cropintel /var/backups/cropintel
Models are also re-fetchable from the release zip, so this is cheap insurance,
not a disaster-recovery plan. Add an rclone copy of /var/backups/cropintel
to object storage if you want offsite copies.
Troubleshooting
| Symptom | Check |
|---|---|
/api/health 503 |
curl localhost:8000/readyz inside the container β shows per-crop load errors |
| "Model not ready" for one crop | that crop has no complete version dir; fetch models or train |
| Predictions slow / queueing | the service is single-worker by design (TFLite interpreters are not thread-safe); sustained load beyond ~10 req/s needs a second look |
| New models bundle ignored | delete ml/models/.cropintel-fetch-ok and restart |
| Rate limiting all users together | proxy not setting X-Forwarded-For |