Sk1-exam/README.md
Gigi Saji 1ac8f3e8ab docs: update README to match deployed reality
- Live URL set to https://savesave.duckdns.org and repo URL added
- Compute row + cost table updated for VM.Standard.E2.1.Micro (was A1.Flex)
- Explain the A1 → E2.1.Micro switch (Out of host capacity) and the local
  Vite build + 2 GB swap mitigations for the 1 GB RAM limit
- Frontend service description updated for single-stage Dockerfile
- New section 2.5 (Ports) and 2.6 (Deployment workflow) for defense talking points
- Prerequisites mention Node 18+ for local Vite build and Git Bash on Windows
- OCI quota check switched to E2.1.Micro limit name
- .gitattributes added to repo layout
2026-05-14 13:26:58 +05:30

369 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# sk1 — Expense Tracker
**Author:** Gigi Saji
**Course exam project:** public cloud web application deployment
**Live URL:** https://savesave.duckdns.org
**Cloud:** Oracle Cloud Infrastructure — `eu-zurich-1` / `Cekz:EU-ZURICH-1-AD-1`
**Repo:** `git@git.kemt.fei.tuke.sk:gs839gf/Sk1-exam.git`
---
## 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** offers two compute shapes that never expire:
- `VM.Standard.A1.Flex` — ARM Ampere, up to 4 OCPU / 24 GB total
- `VM.Standard.E2.1.Micro` — x86 AMD, 1 OCPU / 1 GB, up to 2 instances
plus 200 GB of block storage and 10 TB/month of egress. No other major public cloud (AWS, Azure, GCP) offers a comparable always-free compute slice.
This deployment runs on **`VM.Standard.E2.1.Micro`** (x86_64). My first attempt targeted `A1.Flex` (1 OCPU / 6 GB) but the Zurich A1 pool was at capacity (`Out of host capacity` from the launch API), so I switched to the smaller x86 shape. The 1 GB RAM limit is handled by (a) **building the Vite frontend bundle locally** on my laptop and shipping the static `dist/` to the VM (the Dockerfile is single-stage `nginx + COPY dist`) and (b) creating a **2 GB swap file** on the VM during bootstrap.
### 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.E2.1.Micro`, 1 OCPU, 1 GB RAM, 50 GB boot, Ubuntu 22.04 x86_64 | Runs Docker + the four containers. 2 GB swap added at bootstrap so Postgres init / Docker installs don't OOM. |
**Public IP of the live deployment:** `140.86.210.113``savesave.duckdns.org`.
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 (single-stage `nginx:alpine` + pre-built `dist/`) | Serves the React static bundle on port 80 (internal only). The bundle is built on the laptop by `prepare-app.sh` before upload — the VM never runs Vite. |
| `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.
### 2.5 Ports
| Port | Where | Purpose |
|---|---|---|
| 22 | host (VM) | SSH — used by `prepare-app.sh`, `backup.sh`, `view-logs.sh` |
| 80 | host (VM) → `sk1-caddy` | HTTP — ACME HTTP-01 challenge + redirect to 443 |
| 443 | host (VM) → `sk1-caddy` | HTTPS — public traffic for `savesave.duckdns.org` |
| 3000 | `sk1-backend` (internal) | Express API; Caddy proxies `/api/*` to it |
| 80 | `sk1-frontend` (internal) | nginx serving the React bundle |
| 5432 | `sk1-db` (internal) | Postgres; only `sk1-backend` connects |
**Only Caddy publishes host ports.** The other three containers have no `ports:` block in `docker-compose.yml`, so they are reachable only on the internal Docker bridge `appnet`, by service name. The attack surface from the internet is exactly SSH (key-only) + HTTP + HTTPS, enforced at two layers (OCI security list + VM iptables).
### 2.6 Deployment workflow (what `prepare-app.sh` actually does)
1. **Load + validate** `.env`, auto-generate `POSTGRES_PASSWORD` if blank.
2. **Resolve** tenancy/compartment OCID from `~/.oci/config`, then the latest Ubuntu 22.04 x86_64 image for `E2.1.Micro`.
3. **Create or reuse** the networking objects — VCN, internet gateway, route table, security list (TCP 22/80/443 in), subnet. Reuses by display name, so re-runs are idempotent.
4. **Launch the VM** (`E2.1.Micro`, 50 GB boot, x86_64 Ubuntu image, public IP) and wait for `RUNNING`.
5. **Update DuckDNS** A record → new VM IP **before** Caddy starts (so Let's Encrypt validation works first time).
6. **Wait for SSH** (up to 5 min of 10 s retries).
7. **Bootstrap the VM**: create a 2 GB swap file, install Docker + Compose plugin, open ports 80/443 with idempotent iptables rules, save iptables.
8. **Build the frontend locally** (`npm install` + `npm run build`) — Vite needs more RAM than the Micro shape has, so it runs on the laptop.
9. **Tar + scp** the source + pre-built `dist/` to `/opt/sk1/` on the VM (preserves `logs/` and `backups/` from any previous deploy).
10. **`docker compose up -d --build`** on the VM — builds the two custom images, pulls postgres/caddy/nginx, starts all 4 containers in dependency order (db → backend → frontend → caddy).
11. **Caddy auto-issues** the Let's Encrypt cert via ACME HTTP-01 (~30 s) and stores it in the `caddy_data` named volume.
Total wall-clock time from `./prepare-app.sh` to live HTTPS: **~6 minutes**.
---
## 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 |
|---|---|---|---|
| E2.1.Micro compute | 2 instances (1 OCPU + 1 GB each) | 1 instance (1 OCPU + 1 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 (`savesave.duckdns.org`) | $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
├── .gitattributes ← forces LF line endings on .sh/Dockerfile (cross-platform safety)
├── README.md ← this file
├── frontend/
│ ├── Dockerfile ← single-stage: nginx:alpine + COPY pre-built dist/
│ ├── 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 / Git Bash on Windows (the scripts use Bash and Unix tools)
- **OCI CLI:** installed and configured (`oci setup config`). Test with `oci iam region list`.
- **Node.js 18+ and npm:** required locally because `prepare-app.sh` builds the Vite frontend bundle on your laptop before uploading. Verify with `node --version`.
- **`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 `VM.Standard.E2.1.Micro` slot free (limit is 2). Check the host capacity in your AD with:
```bash
oci limits resource-availability get \
--compartment-id <tenancy-ocid> \
--service-name compute \
--limit-name vm-standard-e2-1-micro-count \
--availability-domain Cekz:EU-ZURICH-1-AD-1
```
*(`VM.Standard.A1.Flex` works too if you have ARM capacity available — see the change required in the `--shape` argument of `prepare-app.sh`. The Zurich A1 pool is often capacity-constrained, which is why this project defaults to the more reliable x86 `E2.1.Micro`.)*
- 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