- 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
369 lines
19 KiB
Markdown
369 lines
19 KiB
Markdown
# 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
|