6.1 KiB
Production deployment (AWS, one server)
One 8 GB server runs everything via docker-compose.prod.yml:
caddy (automatic HTTPS) → web (nginx: React build + /api proxy) → backend
(FastAPI, single worker) + mysql (data on the instance disk), with nightly backups to S3.
8 GB RAM is not optional: uploaded Amazon exports are 300–500 MB and expand to multi-GB while parsing. Validate with your largest real file before buying anything smaller.
Monthly cost: ≈ $50–55 — Lightsail 8 GB $44 (or EC2 t4g.large ≈ $61 with EBS + IPv4),
S3 backups $1.50–3, weekly snapshots $2–4, Route 53 $0.50, Frankfurter FX API $0.
1. Provision
- Lightsail: 8 GB / 2 vCPU / 160 GB SSD instance, Ubuntu 24.04. Attach the included
static IP. (EC2 route:
t4g.large+ 100 GB gp3 EBS + Elastic IP.) - Firewall: allow 22 (your office IPs only), 80, 443. Everything else closed.
- Install Docker + AWS CLI:
curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER # re-login after this sudo apt-get install -y awscli # or the AWS CLI v2 bundle - S3 bucket for backups: create
company-ar-backups, enable versioning, add a lifecycle rule (transition to Glacier/IA after 90 days). Attach an IAM role to the instance allowings3:PutObject,s3:GetObject,s3:ListBucketon that bucket — no access keys on disk. - DNS: A record
ar.<company>.com→ the static IP. Caddy then issues and renews the TLS certificate automatically — there is no certbot step.
2. Configure & start
sudo mkdir -p /opt/ar-aging && sudo chown $USER /opt/ar-aging
cd /opt/ar-aging && git clone <repo-url> . && cd ar-aging-app
cp .env.example .env.production
nano .env.production # fill the PRODUCTION section: AR_DOMAIN, passwords,
# AR_SECRET_KEY (openssl rand -hex 32), backup bucket
# (a pre-filled .env.production with generated credentials already exists on the
# dev machine — copy it to the server instead of re-generating)
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
curl -s https://ar.<company>.com/api/health # {"status":"ok",...}
Every
docker compose ... -f docker-compose.prod.ymlcommand below also needs--env-file .env.production— set an alias once and forget it:alias dcp='docker compose --env-file .env.production -f docker-compose.prod.yml'
3. Create the users (5 logins)
docker compose --env-file .env.production -f docker-compose.prod.yml exec backend \
python manage.py add-user talha --name "Talha Ahmed"
# repeat per user; passwords are prompted, never stored in shell history
docker compose --env-file .env.production -f docker-compose.prod.yml exec backend python manage.py list-users
AR_AUTH=on means the API refuses everything except login/health until users exist.
Password resets: manage.py set-password <username>. Leavers: manage.py deactivate-user.
4. Migrate the existing SQLite data (one-time)
The current data lives in backend/data/ar_aging.db on the dev machine. Do a timed dry
run first — January alone is ~3.4M transaction rows.
# copy the SQLite file to the server first (scp), then from ar-aging-app/:
docker compose --env-file .env.production -f docker-compose.prod.yml cp ./ar_aging.db backend:/tmp/ar_aging.db
docker compose --env-file .env.production -f docker-compose.prod.yml exec backend \
python migrate_sqlite_to_mysql.py --sqlite /tmp/ar_aging.db --dry-run
docker compose --env-file .env.production -f docker-compose.prod.yml exec backend \
python migrate_sqlite_to_mysql.py --sqlite /tmp/ar_aging.db
Verify before anyone uses it: per-table row counts printed by the script must match, and a
spot check to the cent — open the January closing and compare /api/sessions/{id}/reconciliation
final_receivable_usd against the dev machine. Copy backend/data/uploads/ into the
ar_data volume the same way (compose cp ./uploads backend:/data/), then archive the
SQLite file to S3 and retire the dev copy.
One-time cleanup for the historical double-count bug (duplicate upload rows):
docker compose --env-file .env.production -f docker-compose.prod.yml exec backend python manage.py dedupe-files # dry run
docker compose --env-file .env.production -f docker-compose.prod.yml exec backend python manage.py dedupe-files --apply
# then re-process the closings it flagged
5. Backups
chmod +x deploy/backup.sh
crontab -e
# 30 2 * * * /opt/ar-aging/ar-aging-app/deploy/backup.sh >> /var/log/ar-backup.log 2>&1
Three layers: nightly mysqldump + uploads/exports → versioned S3 (the script), weekly
instance snapshots (Lightsail console → enable automatic snapshots), and MySQL's own volume
on the instance disk. Run the restore drill quarterly — commands are at the bottom of
backup.sh.
6. Deploying updates
cd /opt/ar-aging/ar-aging-app
git pull
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
Deploy outside a processing run when possible. If a restart does land mid-run, the closing is auto-marked as interrupted at startup (never stuck on "processing") — just re-run it.
7. Operating notes
- Single backend worker, single instance — by design. Jobs and their progress live
in-process. Do not add
--workersor replicas. - Logs:
docker compose --env-file .env.production -f docker-compose.prod.yml logs -f backend(requests, jobs, FX fetches, logins). Add the CloudWatch agent if you want them off-box. - Health:
GET /api/healthchecks the DB and data-dir and is unauthenticated — point Lightsail/CloudWatch monitoring at it. - Exchange rates: Frankfurter (free, keyless). The only outbound call the app makes; currency codes and dates only. Fetched rates still require in-app confirmation (C5).
- Upgrade path (not needed at this scale): move MySQL to RDS
db.t4g.small(+~$30/mo, point-in-time restore) by settingMYSQL_HOSTto the RDS endpoint and removing the mysql service; move exports to S3-primary with presigned URLs if the disk ever tightens.