# 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://`. 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 \ --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