130 lines
6.1 KiB
Markdown
130 lines
6.1 KiB
Markdown
# 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.<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.
|