Your backtest works. Your notebook produces signals. Now it has to run at 9:31 every morning without you — through reboots, API outages, fat-fingered configs, and the Tuesday you’re on a plane. The gap between those two states is not more strategy code; it’s an operating stack, and the minimal version is smaller than most engineers assume: one VPS, five containers, one database, and about a day of setup.
This is the how companion to our production-readiness checklist — that guide says what must be true; this one shows the smallest stack that makes it true. Scope: a solo operator or small team running daily-to-intraday strategies through a broker/exchange API. HFT has different answers at every layer.
The stack at a glance
| Layer | Minimal choice | Upgrade when |
|---|---|---|
| Host | One $10–25/mo VPS (Hetzner/DO/Vultr class) | Second region only when a real DR plan exists |
| Runtime | Docker Compose, restart policies | Kubernetes: almost never for this workload |
| State | Postgres (add Timescale for intraday bars) | Managed Postgres when ops time > $30/mo of value |
| Scheduling | cron or systemd timers + run-locking | Airflow/Prefect only with many interdependent jobs |
| Secrets | .env at 600 + Docker secrets | SOPS/age or a vault when a second human joins |
| Logs | JSON to stdout → journald/Docker, 30-day rotation | Loki/hosted only when you actually grep across weeks |
| Metrics/alerts | Dead-man heartbeat (Healthchecks/Uptime Kuma) + ntfy to phone | Prometheus + Grafana at 2+ live strategies |
| Backups | Nightly pg_dump → offsite object storage + restore drill | Streaming replication when RPO of 24h hurts |
The theme: every component is boring on purpose. Your strategy is the experiment; the stack around it must not be.
1. One VPS, containers as the unit of operation
Run everything as Docker Compose services on a single small VPS. Containers earn their place here for three unglamorous reasons: the image pins your Python and dependency versions permanently (the checklist’s reproducible-environment box), restart: unless-stopped gives you crash recovery without writing supervisor configs, and a deploy becomes git pull && docker compose up -d --build — which means rollback is git checkout <tag> and the same command.
The skeleton that carries the whole guide:
# docker-compose.yml
services:
strategy:
build: .
restart: unless-stopped
env_file: .env # secrets live here, mode 600, not in git
depends_on: [db]
logging:
driver: json-file
options: { max-size: "20m", max-file: "5" }
watchdog: # kill switch: SEPARATE container, minimal deps
build: ./watchdog
restart: unless-stopped
env_file: .env.watchdog # its own broker credentials, read-only where possible
db:
image: timescale/timescaledb:latest-pg16
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/pg_pass
secrets: [pg_pass]
volumes: ["pgdata:/var/lib/postgresql/data"]
secrets:
pg_pass: { file: ./secrets/pg_pass }
volumes:
pgdata:
Notes that save future pain: pin image tags in real life (timescaledb:2.17.2-pg16, not latest); the watchdog deliberately does not share code or an image with the strategy — an independent process that flattens positions on heartbeat loss is the highest-value component in the stack and it must not inherit the strategy’s bugs; and put the VPS behind key-only SSH with a firewall allowing 22 and nothing else inbound — this stack needs no open ports.
2. Postgres is the source of truth, not your process memory
The single biggest architectural decision: the strategy process must be disposable. Kill it at any moment and a restart rebuilds state from two places only — the broker API and the database. Anything held in memory or in loose files is state you’ll lose at the worst time.
Minimum schema, four tables:
CREATE TABLE runs (id bigserial PRIMARY KEY, started_at timestamptz DEFAULT now(),
finished_at timestamptz, status text, detail jsonb);
CREATE TABLE decisions (id bigserial PRIMARY KEY, run_id bigint REFERENCES runs,
ts timestamptz DEFAULT now(), symbol text, action text,
inputs jsonb, reason text); -- the decision log
CREATE TABLE orders (id bigserial PRIMARY KEY, client_order_id text UNIQUE,
decision_id bigint REFERENCES decisions, ts timestamptz,
symbol text, side text, qty numeric, status text, broker_id text);
CREATE TABLE fills (id bigserial PRIMARY KEY, order_id bigint REFERENCES orders,
ts timestamptz, qty numeric, price numeric, fee numeric);
client_order_id UNIQUE is doing real work: it’s the database-enforced half of order idempotency — on any uncertainty, reconcile against the broker before re-sending, and the constraint makes accidental double-submission a loud error instead of a double position. The decisions table with full inputs jsonb is what lets you answer “why did it trade?” months later.
Do you need Timescale? For a daily strategy storing signals and orders: plain Postgres, no. The Timescale extension earns its place when you’re persisting intraday bars or tick captures next to your operational tables — hypertables + compression keep that from eating the disk. Either way this pairs with the research-side storage question covered in the tick-database guide. And honestly: a single-process daily strategy can run on SQLite — you give up concurrent access for the watchdog and painless backups, which is exactly why we don’t recommend it once real money flows.
3. Scheduling: cron is fine, double-runs are not
For a handful of independent jobs (pre-market prep, signal run, EOD reconciliation), cron or systemd timers are the correct tool — Airflow at this scale is an incident generator with a UI. What cron does not give you is protection from the two classic failures: overlapping runs and silent no-runs.
Overlap protection in one line with a Postgres advisory lock (works across containers, unlike flock on a file):
got_lock = db.execute("SELECT pg_try_advisory_lock(42)").scalar()
if not got_lock:
log.warning("previous run still holds the lock; exiting"); sys.exit(0)
Silent no-runs are solved by the dead-man pattern in §5 — the job’s absence pages you, cron’s own logs never will. And record every invocation as a runs row first thing; “when did this last actually execute” should be a SQL query, not an archaeology dig through syslog.
One non-negotiable from the checklist: the host runs NTP and every timestamp in the system is UTC. Exchange-calendar logic (holidays, half-days, DST transitions against a US-market clock) lives in one tested module, not scattered through crontabs.
4. Secrets: boring, strict, rotatable
The minimal discipline that survives audits and mistakes alike:
- Secrets live in
.envfiles owned by the deploy user, mode600, listed in.gitignore, and injected viaenv_file:/Docker secrets — never baked into images, never in compose files, never in code. - Separate credentials per component. The strategy gets trade-scoped API keys; the watchdog gets its own; backups get read-only DB access. When one leaks, you rotate one.
- Write the rotation procedure down now (where each key is issued, where it’s referenced, how to verify the old one is dead). Rotation you’ve never rehearsed is downtime with extra steps.
- If your broker supports IP allowlisting, pin the API keys to the VPS’s address — it converts a leaked key from an emergency into a nuisance.
Upgrade to SOPS/age-encrypted secrets in git, or a proper vault, when a second person needs access — not before.
5. Logs, metrics, and the alert that actually matters
Logs: emit structured JSON to stdout and let Docker/journald handle files and rotation (the logging: block above caps it at 100MB). Two streams matter and they are not the same thing: the application log (exceptions, API latencies, retries) and the decision log (§2, in Postgres, queryable forever). Ship logs to Loki/a hosted service only when you find yourself actually needing cross-week searches.
The one alert that matters is the dead-man switch: the strategy pings after each successful cycle; the monitor pages you when the ping doesn’t arrive. This inverts the failure mode — crash, hang, expired credentials, dead VPS, and broken cron all collapse into the same phone notification.
# last line of a successful run
curl -fsS -m 10 "https://hc-ping.com/<uuid>" # Healthchecks.io (hosted, free tier)
# or self-hosted Uptime Kuma's push monitor — same pattern
Route pages through ntfy (self-hostable) or Pushover/Telegram — anything that vibrates a phone. Email is where alerts go to be read on Thursday. The watchdog container consumes the same heartbeats from Postgres and acts (flatten, halt) instead of merely notifying — alerting tells you, the kill switch protects the account, and you need both because you sleep.
Full Prometheus + Grafana is genuinely worth it at two or more live strategies or when you start caring about API-latency trends — as a starting point it’s ceremony. A daily P&L-and-positions summary pushed to the same phone channel doubles as your reconciliation nudge.
6. Backups you have restored at least once
The state worth money is small: the Postgres volume and your config. That makes the minimal plan cheap and honest:
# /etc/cron.d/backup — 02:15 UTC nightly
15 2 * * * deploy docker exec db pg_dump -U postgres -Fc trading \
| age -r $BACKUP_PUBKEY \
| rclone rcat r2:tq-backups/pg/trading-$(date -u +\%F).dump.age \
&& curl -fsS -m 10 https://hc-ping.com/<backup-check-uuid>
Encrypted with age, shipped off the VPS to object storage (R2/S3/B2 — cents per month at this size), with its own dead-man check so a silently failing backup pages you like a silently failing strategy. Keep 30 daily + 12 monthly via a lifecycle rule. Code and config need no separate backup — they’re in git; the .env secrets go in your password manager, not in the backup stream.
The part everyone skips: quarterly, restore the latest dump to a scratch container and run one query against it. The checklist’s kill-switch drill and this restore drill are the same idea — an untested recovery path is a decoration. RPO here is 24 hours of operational metadata (fills are recoverable from the broker); if that ever becomes unacceptable, that’s the trigger for WAL archiving or streaming replication, not before.
7. Incident response for a team of one
You will have incidents; the difference between a bad hour and a bad quarter is whether the middle-of-the-night decisions were made in advance:
- A one-page runbook in the repo: how to halt trading, how to flatten manually at the broker, how to restart from nothing on a fresh VPS (you tested this when you set up backups), who to call at the broker.
- An incident log (a markdown file is fine): timestamp, symptom, cause, fix, follow-up. Its compounding value is the pattern — the third entry about the same component is a redesign order, per the checklist.
- Pre-decided halt conditions. Data feed stale > N minutes → halt new orders. Reconciliation mismatch → halt and page. Drawdown breach → watchdog flattens without asking you. The strategy resumes only by explicit human action — auto-resume after an unexplained halt is how one incident becomes two.
Use this stack if / don’t use it if
Use this if: you run 1–3 strategies at minute-to-daily horizons through a broker API, you are the ops team, and your monthly infrastructure budget has one digit before the comma. This stack — VPS, Compose, Postgres, cron with locks, dead-man alerts, offsite dumps — covers every box in Gates 3–5 of the checklist for roughly $15–35/month all-in.
Don’t use this if: latency is your edge (you need proximity hosting and a different article), you have a team (add SOPS, CI deploys, and real on-call rotation), regulation requires audit infrastructure beyond a decision log, or you haven’t passed Gate 1 validation yet — production infrastructure for an unvalidated strategy is a way to lose money more reliably.
Tool mentions are editorial, dated September 2026; no affiliate links or sponsored placements on this page — if that changes it will be disclosed inline. Verify pricing before committing.
The checklist this implements: Production-readiness checklist → (download the .md) · The research side: A practical quant research stack →