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 |
||
|---|---|---|
| backend | ||
| docs | ||
| frontend | ||
| scripts | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| Caddyfile | ||
| docker-compose.yml | ||
| prepare-app.sh | ||
| README.md | ||
| remove-app.sh | ||
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-alpine → nginx: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 filescaddy_data(named) — Let's Encrypt certificates and ACME state, survives restartscaddy_config(named) — Caddy runtime config./logs/caddy(host bind mount) — JSON access logs, viewable from the laptop./backups(host bind mount) — destination forbackup.shdumps
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.ymldefines all services, ports, volumes, restart policies, healthchecks — versioned in git, not in source code.Caddyfileholds the proxy and HTTPS config — versioned in git..envholds secrets and per-deployment variables — gitignored. Docker Compose substitutes${VAR}references from this file at start time.backend/db/init.sqlis 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
- Run
./prepare-app.sh(see Section 8 for prerequisites). - Wait for the final line:
Deployed → https://<your-domain>. - Open that URL in any modern browser. First load may take ~30 seconds while Caddy obtains the TLS certificate.
- 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.
- 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 withoci iam region list. curl,openssl,ssh,scp,tar,gzip: standard Unix tools, all needed byprepare-app.sh.jqis optional —scripts/view-logs.shuses it for pretty-printing if available, otherwise it falls back tocat.- 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
- 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.shboilerplate 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
.envvalues - My SSH keys
- My OCI tenancy / DuckDNS account credentials
- My