exam
Go to file
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
backend Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30
docs Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30
frontend Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30
scripts Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30
.env.example Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30
.gitattributes Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30
.gitignore Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30
Caddyfile Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30
docker-compose.yml Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30
prepare-app.sh Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30
README.md docs: update README to match deployed reality 2026-05-14 13:26:58 +05:30
remove-app.sh Initial commit: Expense Tracker on Oracle Cloud (sk1 exam) 2026-05-14 12:53:45 +05:30

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.113savesave.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

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

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