# 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 1. **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.) 2. Firewall: allow 22 (your office IPs only), 80, 443. Everything else closed. 3. Install Docker + AWS CLI: ```bash 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 ``` 4. **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 allowing `s3:PutObject`, `s3:GetObject`, `s3:ListBucket` on that bucket — no access keys on disk. 5. **DNS**: A record `ar..com` → the static IP. Caddy then issues and renews the TLS certificate automatically — there is no certbot step. ## 2. Configure & start ```bash sudo mkdir -p /opt/ar-aging && sudo chown $USER /opt/ar-aging cd /opt/ar-aging && git clone . && 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..com/api/health # {"status":"ok",...} ``` > Every `docker compose ... -f docker-compose.prod.yml` command 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) ```bash 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 `. 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. ```bash # 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): ```bash 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 ```bash 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 ```bash 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 `--workers` or 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/health` checks 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 setting `MYSQL_HOST` to the RDS endpoint and removing the mysql service; move exports to S3-primary with presigned URLs if the disk ever tightens.