Finance-Accounts/ar-aging-app/deploy/DEPLOY.md

130 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 300500 MB and expand to multi-GB
while parsing. Validate with your largest real file before buying anything smaller.
Monthly cost: **≈ $5055** — Lightsail 8 GB $44 (or EC2 `t4g.large` ≈ $61 with EBS + IPv4),
S3 backups $1.503, weekly snapshots $24, 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.<company>.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 <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.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 <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.
```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.