Sk1-exam/README.md
Gigi Saji 260b60622f Initial commit: Expense Tracker on Oracle Cloud (sk1 exam)
Public-cloud deployment of a single-user expense tracker:
- 4-container stack: Caddy (HTTPS via Let's Encrypt), nginx (React/Vite SPA), Express API, Postgres 16
- Repeatable deployment via prepare-app.sh using only OCI CLI (no web console)
- Persistent Postgres volume, auto-restart policies, healthchecks, backup + restore scripts
- DuckDNS dynamic DNS for the public hostname; secrets isolated to gitignored .env

Author: Gigi Saji
Live URL: https://savesave.duckdns.org
2026-05-14 12:53:45 +05:30

15 KiB
Raw Blame History

sk1 — Expense Tracker on Oracle Cloud

Author: Gigi Saji Course exam project: public cloud web application deployment Live URL: https://<your-duckdns-domain>.duckdns.org (after prepare-app.sh runs)


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-alpinenginx: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

./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:

./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.

./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:
    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

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

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

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