327 lines
15 KiB
Markdown
327 lines
15 KiB
Markdown
# sk1 — Expense Tracker
|
||
|
||
**Author:** Gigi Saji
|
||
**Course exam project:** public cloud web application deployment
|
||
**Live URL:** ("https://savesave.duckdns.org")
|
||
|
||
---
|
||
|
||
## 1. What the application does
|
||
|
||
**Expense Tracker** is a small personal-finance web app for logging where your money goes. It is intentionally minimal:
|
||
|
||
- Add an expense — amount, category, note, date
|
||
- See all expenses, most recent first
|
||
- Delete any expense
|
||
- See a live total for the current month, an entry count, and a category count
|
||
|
||
It is a **single-user** app — there is no login, no signup. The UI is built with React, Vite, Tailwind CSS, and Lucide icons for a clean, modern look. The backend is a small Node/Express API talking to a PostgreSQL database. All traffic is served over HTTPS via Caddy with an automatic Let's Encrypt certificate.
|
||
|
||
**Who it's for:** anyone who wants a quick personal expense log they fully control, deployed cheaply (or freely) on their own cloud account.
|
||
|
||
---
|
||
|
||
## 2. Cloud architecture
|
||
|
||
### 2.1 Public cloud used
|
||
|
||
**Oracle Cloud Infrastructure (OCI)** — region `eu-zurich-1`, availability domain `Cekz:EU-ZURICH-1-AD-1`.
|
||
|
||
I chose OCI because the **Always Free tier** includes a generous ARM-based compute instance (`VM.Standard.A1.Flex` — up to 4 OCPUs and 24 GB RAM total, never expires) and 200 GB of block storage, which is more than enough to host the entire stack at zero cost forever. No other major public cloud (AWS, Azure, GCP) offers a comparable always-free compute slice.
|
||
|
||
### 2.2 Cloud resources
|
||
|
||
| Resource | Type | Purpose |
|
||
|---|---|---|
|
||
| VCN (`sk1-vcn`) | Virtual Cloud Network, `10.10.0.0/16` | Network isolation for the VM |
|
||
| Subnet (`sk1-subnet`) | Public subnet, `10.10.1.0/24` | Hosts the VM, attached to the IG via route table |
|
||
| Internet Gateway (`sk1-ig`) | IGW | Egress + ingress for the public subnet |
|
||
| Route Table (`sk1-rt`) | Default route `0.0.0.0/0 → sk1-ig` | Routes outbound traffic |
|
||
| Security List (`sk1-seclist`) | Stateful firewall | Allows TCP 22, 80, 443 inbound; all outbound |
|
||
| Compute Instance (`sk1-expense-tracker`) | `VM.Standard.A1.Flex`, 1 OCPU, 6 GB RAM, 50 GB boot, Ubuntu 22.04 aarch64 | Runs Docker + the four containers |
|
||
|
||
The existing VM (`gymsys-server`) is on a different shape and is **not touched** by any script in this project.
|
||
|
||
### 2.3 Docker objects
|
||
|
||
The app runs as a Docker Compose stack with four services:
|
||
|
||
| Service | Image | Role |
|
||
|---|---|---|
|
||
| `caddy` | `caddy:2-alpine` | Reverse proxy. Terminates HTTPS via Let's Encrypt, routes `/api/*` to backend and everything else to frontend, writes JSON access logs |
|
||
| `frontend` | custom (multi-stage `node:20-alpine` → `nginx:alpine`) | Serves the built React static bundle on port 80 (internal only) |
|
||
| `backend` | custom (`node:20-alpine`) | Express API listening on port 3000 (internal only). Endpoints: `GET /api/expenses`, `GET /api/expenses/summary`, `POST /api/expenses`, `DELETE /api/expenses/:id`, `GET /health` |
|
||
| `db` | `postgres:16-alpine` | PostgreSQL database, writes to the `postgres_data` named volume; runs `init.sql` once on first start |
|
||
|
||
**Networking:** Caddy is the only container with published ports (80 and 443 on the host). The other three are reachable only on the internal Docker network `appnet`. The backend reaches Postgres by the service name `db`; Caddy reaches the frontend and backend by service name.
|
||
|
||
**Persistent volumes:**
|
||
- `postgres_data` (named, Docker-managed) — Postgres data files
|
||
- `caddy_data` (named) — Let's Encrypt certificates and ACME state, survives restarts
|
||
- `caddy_config` (named) — Caddy runtime config
|
||
- `./logs/caddy` (host bind mount) — JSON access logs, viewable from the laptop
|
||
- `./backups` (host bind mount) — destination for `backup.sh` dumps
|
||
|
||
**Restart policy:** all four services use `restart: unless-stopped`, so they automatically come back after the VM reboots or a container crashes. `db` has a `pg_isready` healthcheck; `backend` has a `/health` healthcheck; `backend` waits for `db` to be healthy before starting.
|
||
|
||
### 2.4 Communication flow
|
||
|
||
```
|
||
Internet ──:443──► caddy ──/───► frontend:80 (static React bundle)
|
||
└─/api/*► backend:3000 (Express API)
|
||
│
|
||
└────► db:5432 (Postgres, internal only)
|
||
```
|
||
|
||
DNS is provided by **DuckDNS** (free dynamic DNS). `prepare-app.sh` updates the A record to point at the VM's public IP **before** starting Caddy, so Let's Encrypt's HTTP-01 challenge succeeds on the first try.
|
||
|
||
---
|
||
|
||
## 3. Cost analysis — 1000 users/day, 50 GB data, one year
|
||
|
||
This is a hypothetical paid-tier sizing exercise. The **actual deployment of this app on Always Free costs $0/year**.
|
||
|
||
### 3.1 Workload sizing assumptions
|
||
|
||
- 1000 daily active users × 20 API requests/user = ~20 000 requests/day
|
||
- Average response 5 KB → ~100 MB egress/day → **~3 GB/month** (well under the 10 TB free egress)
|
||
- 50 GB database (heavy users / years of history)
|
||
- Modest concurrent load → 2 OCPU + 8 GB RAM is comfortable
|
||
|
||
### 3.2 OCI paid-tier monthly cost
|
||
|
||
| Resource | Spec | Unit price (OCI, EU-Zurich, May 2026) | Monthly |
|
||
|---|---|---|---|
|
||
| Compute — `VM.Standard.E5.Flex` | 2 OCPU + 8 GB RAM, 730 h/mo | $0.0250 / OCPU-hour + $0.0015 / GB-hour | $45.36 |
|
||
| Block storage — boot + data | 100 GB (50 GB DB + headroom) | $0.0255 / GB / month | $2.55 |
|
||
| Block storage backup policy | 50 GB snapshots, daily/weekly | $0.0255 / GB / month | $1.28 |
|
||
| Egress | ~3 GB/month | First 10 TB free → $0 | $0.00 |
|
||
| **Subtotal** | | | **~$49.19** |
|
||
| **Annual cost** | | | **~$590** |
|
||
|
||
**Optional Load Balancer** (if you want a managed LB instead of Caddy on the VM): adds **$18.00/month → $216/year**, bringing the total to ~$806/year.
|
||
|
||
### 3.3 Billing intervals
|
||
|
||
- Compute: per-second, rounded up to the minute (minimum 1 minute per instance start)
|
||
- Block storage: per-hour, prorated
|
||
- Load Balancer: per-hour
|
||
- Egress: per-GB, billed monthly after the 10 TB free tier
|
||
|
||
### 3.4 What the Always Free tier covers (this project's actual cost)
|
||
|
||
| Resource | Free tier limit | This app uses | Annual cost |
|
||
|---|---|---|---|
|
||
| A1.Flex compute | 4 OCPU + 24 GB total | 1 OCPU + 6 GB | $0.00 |
|
||
| Block storage | 200 GB tenancy-wide | 50 GB (boot volume) | $0.00 |
|
||
| Egress | 10 TB/month | < 1 GB/month | $0.00 |
|
||
| DuckDNS DNS | Free | 1 hostname | $0.00 |
|
||
| Let's Encrypt cert | Free | 1 cert | $0.00 |
|
||
| **Total** | | | **$0.00 / year** |
|
||
|
||
---
|
||
|
||
## 4. Repository layout and what each file is
|
||
|
||
```
|
||
sk1/
|
||
├── prepare-app.sh ← provision VM + deploy app (single command)
|
||
├── remove-app.sh ← tear down VM and all sk1 resources
|
||
├── docker-compose.yml ← 4-service stack definition
|
||
├── Caddyfile ← Caddy config: HTTPS, reverse proxy rules, access logs
|
||
├── .env.example ← template for secrets (committed)
|
||
├── .env ← real secrets (gitignored, never committed)
|
||
├── .gitignore ← keeps secrets, build artifacts, logs out of git
|
||
├── README.md ← this file
|
||
│
|
||
├── frontend/
|
||
│ ├── Dockerfile ← multi-stage: build React → serve with nginx
|
||
│ ├── nginx.conf ← nginx config (SPA fallback to index.html)
|
||
│ ├── package.json ← React + Vite + Tailwind deps
|
||
│ ├── tsconfig.json ← TypeScript config
|
||
│ ├── vite.config.ts ← Vite build + local dev proxy
|
||
│ ├── tailwind.config.js ← Tailwind content paths + theme
|
||
│ ├── postcss.config.js ← PostCSS plugin chain
|
||
│ ├── index.html ← root HTML, loads Inter font
|
||
│ └── src/
|
||
│ ├── main.tsx ← React entry point
|
||
│ ├── App.tsx ← the expense tracker UI
|
||
│ ├── styles.css ← Tailwind directives + base styles
|
||
│ └── lib/api.ts ← typed fetch wrapper for the backend
|
||
│
|
||
├── backend/
|
||
│ ├── Dockerfile ← node:20-alpine, runs as non-root
|
||
│ ├── package.json ← express + pg
|
||
│ ├── src/index.js ← Express app: 5 endpoints + healthcheck
|
||
│ └── db/init.sql ← schema bootstrap (runs once on first DB init)
|
||
│
|
||
├── scripts/
|
||
│ ├── backup.sh ← pg_dump → gzipped file on laptop
|
||
│ ├── restore.sh ← restore a backup (interactive confirmation)
|
||
│ └── view-logs.sh ← tail / show Caddy access logs from the VM
|
||
│
|
||
├── logs/ ← Caddy access logs (bind-mounted from VM, local dev)
|
||
└── backups/ ← destination for backup.sh dumps
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Configuration
|
||
|
||
### 5.1 How configuration is loaded
|
||
|
||
- **`docker-compose.yml`** defines all services, ports, volumes, restart policies, healthchecks — versioned in git, not in source code.
|
||
- **`Caddyfile`** holds the proxy and HTTPS config — versioned in git.
|
||
- **`.env`** holds secrets and per-deployment variables — gitignored. Docker Compose substitutes `${VAR}` references from this file at start time.
|
||
- **`backend/db/init.sql`** is the schema — versioned in git, executed once by Postgres on first startup.
|
||
|
||
The application itself reads its configuration from environment variables only:
|
||
- Frontend has no runtime config (the API base path is hardcoded to `/api`, served by Caddy from the same origin).
|
||
- Backend reads `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `PORT`.
|
||
- Caddy reads `{$DUCKDNS_DOMAIN}` and `{$LE_EMAIL}` from its environment.
|
||
|
||
### 5.2 Required `.env` keys
|
||
|
||
Copy `.env.example` to `.env` and fill in:
|
||
|
||
| Key | What |
|
||
|---|---|
|
||
| `POSTGRES_USER` | DB role name (default `expenses`) |
|
||
| `POSTGRES_DB` | DB name (default `expenses`) |
|
||
| `POSTGRES_PASSWORD` | Leave blank — `prepare-app.sh` generates a strong random password |
|
||
| `DUCKDNS_DOMAIN` | e.g. `gigi-expenses.duckdns.org` |
|
||
| `DUCKDNS_TOKEN` | Get from https://www.duckdns.org/ → sign in → copy token |
|
||
| `LE_EMAIL` | Email for Let's Encrypt renewal notices |
|
||
| `OCI_VM_NAME` | VM display name (default `sk1-expense-tracker`) |
|
||
| `OCI_AVAILABILITY_DOMAIN` | AD (default `Cekz:EU-ZURICH-1-AD-1`) |
|
||
| `OCI_SSH_PUBLIC_KEY_PATH` | e.g. `~/.ssh/id_ed25519.pub` |
|
||
| `OCI_SSH_PRIVATE_KEY_PATH` | e.g. `~/.ssh/id_ed25519` |
|
||
|
||
**Secrets are never in git.** The .gitignore at the project root excludes `.env`, any `*.pem`, OCI key files, etc.
|
||
|
||
---
|
||
|
||
## 6. How to view and use the application
|
||
|
||
1. Run `./prepare-app.sh` (see Section 8 for prerequisites).
|
||
2. Wait for the final line: `Deployed → https://<your-domain>`.
|
||
3. Open that URL in any modern browser. First load may take ~30 seconds while Caddy obtains the TLS certificate.
|
||
4. The UI loads:
|
||
- **Top stat cards:** this month's total, total entries, distinct categories.
|
||
- **Add expense form:** amount (required, numeric), category (dropdown), date (defaults to today), optional note.
|
||
- **Recent expenses list:** delete with the trash icon on each row.
|
||
5. Try adding a few expenses, refreshing the page (data persists), then deleting one.
|
||
|
||
The app works the same on desktop and mobile (the layout is responsive).
|
||
|
||
---
|
||
|
||
## 7. Backups and access logs
|
||
|
||
### 7.1 Performing a backup
|
||
|
||
```bash
|
||
./scripts/backup.sh
|
||
# → backups/expenses-20260514-143022.sql.gz
|
||
```
|
||
|
||
This SSHes to the VM, runs `pg_dump -Fc` inside the `sk1-db` container, streams the dump back, and gzips it locally. The backup file is fully self-contained and can be restored to a fresh Postgres 16 instance.
|
||
|
||
To restore:
|
||
|
||
```bash
|
||
./scripts/restore.sh backups/expenses-20260514-143022.sql.gz
|
||
# (prompts for confirmation; wipes existing data)
|
||
```
|
||
|
||
### 7.2 Viewing access records from the Internet
|
||
|
||
Caddy writes one JSON record per HTTP request to `/var/log/caddy/access.log` inside the container, bind-mounted to `/opt/sk1/logs/caddy/access.log` on the VM.
|
||
|
||
```bash
|
||
./scripts/view-logs.sh # tail -f, live stream
|
||
./scripts/view-logs.sh recent # last 50 entries, pretty-printed JSON
|
||
```
|
||
|
||
Each entry includes timestamp, client IP, request method/path, response status, response size, user-agent, and TLS info. Logs auto-rotate at 10 MB and keep 5 archives (~30 days).
|
||
|
||
---
|
||
|
||
## 8. Conditions to run the scripts
|
||
|
||
### 8.1 Prerequisites on your laptop
|
||
|
||
- **OS:** Linux / macOS / WSL2 (the scripts use Bash and Unix tools)
|
||
- **OCI CLI:** installed and configured (`oci setup config`). Test with `oci iam region list`.
|
||
- **`curl`, `openssl`, `ssh`, `scp`, `tar`, `gzip`:** standard Unix tools, all needed by `prepare-app.sh`. `jq` is optional — `scripts/view-logs.sh` uses it for pretty-printing if available, otherwise it falls back to `cat`.
|
||
- **SSH key pair:** an Ed25519 or RSA key pair. Path is set in `.env`.
|
||
|
||
### 8.2 OCI account requirements
|
||
|
||
- A tenancy with at least one Always Free A1.Flex slice available — `1 OCPU + 6 GB RAM`. Check with:
|
||
```bash
|
||
oci limits resource-availability get \
|
||
--compartment-id <tenancy-ocid> \
|
||
--service-name compute \
|
||
--limit-name standard-a1-core-count \
|
||
--availability-domain Cekz:EU-ZURICH-1-AD-1
|
||
```
|
||
- At least 50 GB free in the 200 GB Always Free block-storage pool.
|
||
|
||
### 8.3 External services
|
||
|
||
- **DuckDNS account** (free) with a hostname registered and a token copied.
|
||
|
||
### 8.4 Running `prepare-app.sh`
|
||
|
||
```bash
|
||
cd sk1
|
||
cp .env.example .env # then edit .env
|
||
./prepare-app.sh
|
||
```
|
||
|
||
The script is **idempotent**: re-running it will reuse any existing VCN/subnet/security list/VM with the same name rather than creating duplicates. It only touches resources it created (matched by display name `sk1-*` or by the specific VM OCID).
|
||
|
||
### 8.5 Running `remove-app.sh`
|
||
|
||
```bash
|
||
cd sk1
|
||
./remove-app.sh
|
||
```
|
||
|
||
Removes (in this order): the Compose stack on the VM, the VM, the subnet, security list, route table, internet gateway, VCN. Resets the DuckDNS A record to a placeholder. **Does not touch** any resource not named `sk1-*` or `sk1-expense-tracker`.
|
||
|
||
---
|
||
|
||
## 9. External resources and AI tool use
|
||
|
||
### 9.1 Documentation consulted
|
||
|
||
- React 18 docs — https://react.dev/
|
||
- Vite docs — https://vitejs.dev/
|
||
- Tailwind CSS docs — https://tailwindcss.com/docs
|
||
- Lucide React icons — https://lucide.dev/
|
||
- Caddy 2 docs — https://caddyserver.com/docs/
|
||
- Docker Compose spec — https://docs.docker.com/compose/
|
||
- PostgreSQL 16 docs — https://www.postgresql.org/docs/16/
|
||
- OCI CLI command reference — https://docs.oracle.com/iaas/tools/oci-cli/
|
||
- DuckDNS install guide — https://www.duckdns.org/install.jsp
|
||
- Let's Encrypt ACME flow — https://letsencrypt.org/how-it-works/
|
||
|
||
### 9.2 Generative AI assistance
|
||
|
||
- **Tool:** Claude (Anthropic), specifically Claude Opus 4.7 (1M context), used via the Claude Code CLI.
|
||
- **How used:**
|
||
- Discussion of architecture trade-offs (Docker Compose vs Kubernetes, Oracle vs Render vs Vercel)
|
||
- Drafting `prepare-app.sh` boilerplate around the OCI CLI calls
|
||
- Drafting the React component code based on a high-level UI description
|
||
- Drafting this README
|
||
- **What I did:**
|
||
- All design decisions (cloud, shape, scope, tech choices) were mine.
|
||
- All generated code was reviewed before being saved.
|
||
- I tested every script and adjusted commands where the AI's first output didn't match the current OCI CLI flags or my local environment.
|
||
- **What was NOT generated:**
|
||
- My `.env` values
|
||
- My SSH keys
|
||
- My OCI tenancy / DuckDNS account credentials
|