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
This commit is contained in:
Giji Saji 2026-05-14 12:53:45 +05:30
commit 260b60622f
29 changed files with 4351 additions and 0 deletions

28
.env.example Normal file
View File

@ -0,0 +1,28 @@
# Copy this file to .env and fill in real values.
# .env is gitignored — never commit secrets.
# ---- Database ----
POSTGRES_USER=expenses
POSTGRES_DB=expenses
# Leave empty and prepare-app.sh will generate a strong random password
POSTGRES_PASSWORD=
# ---- DNS / HTTPS ----
# Your DuckDNS hostname (without https://), e.g. myexpenses.duckdns.org
DUCKDNS_DOMAIN=
# Your DuckDNS token (https://www.duckdns.org/ → Sign in → copy token)
DUCKDNS_TOKEN=
# Email Let's Encrypt will use for renewal notices
LE_EMAIL=
# ---- OCI / VM ----
# Display name for the new VM (kept distinct from existing gymsys-server)
OCI_VM_NAME=sk1-expense-tracker
# OCID of compartment to deploy into (default: tenancy root)
OCI_COMPARTMENT_ID=
# Path to your SSH public key (used to log into the VM)
OCI_SSH_PUBLIC_KEY_PATH=~/.ssh/id_ed25519.pub
# Path to your SSH private key (used by scripts to ssh in)
OCI_SSH_PRIVATE_KEY_PATH=~/.ssh/id_ed25519
# Availability domain
OCI_AVAILABILITY_DOMAIN=Cekz:EU-ZURICH-1-AD-1

8
.gitattributes vendored Normal file
View File

@ -0,0 +1,8 @@
* text=auto
*.sh text eol=lf
Dockerfile text eol=lf
Caddyfile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf

28
.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Secrets — NEVER commit
.env
*.env.local
*.pem
*.key
oci_api_key*
.oci/
# Runtime state
backups/
logs/
caddy_data/
caddy_config/
postgres_data/
# Build artifacts
node_modules/
dist/
build/
.vite/
*.log
# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp

31
Caddyfile Normal file
View File

@ -0,0 +1,31 @@
{
email {$LE_EMAIL}
admin off
}
{$DUCKDNS_DOMAIN} {
encode zstd gzip
# API requests go to the Express backend
handle /api/* {
reverse_proxy backend:3000
}
handle /health {
reverse_proxy backend:3000
}
# Everything else is the static frontend
handle {
reverse_proxy frontend:80
}
log {
output file /var/log/caddy/access.log {
roll_size 10mb
roll_keep 5
roll_keep_for 720h
}
format json
}
}

326
README.md Normal file
View File

@ -0,0 +1,326 @@
# 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 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
```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 (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:
```bash
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`
```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

16
backend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund \
&& apk add --no-cache wget
COPY src ./src
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "src/index.js"]

13
backend/db/init.sql Normal file
View File

@ -0,0 +1,13 @@
-- Schema bootstrap. Runs only once, when the data volume is empty
-- (postgres official image executes /docker-entrypoint-initdb.d/ files on first init).
CREATE TABLE IF NOT EXISTS expenses (
id SERIAL PRIMARY KEY,
amount NUMERIC(12,2) NOT NULL,
category TEXT NOT NULL,
note TEXT,
spent_at DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS expenses_spent_at_idx ON expenses (spent_at DESC);

18
backend/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "expense-tracker-backend",
"version": "1.0.0",
"description": "Express API for the expense tracker",
"private": true,
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "^4.19.2",
"pg": "^8.11.5"
},
"engines": {
"node": ">=20"
}
}

109
backend/src/index.js Normal file
View File

@ -0,0 +1,109 @@
import express from 'express';
import pg from 'pg';
const { Pool } = pg;
const PORT = parseInt(process.env.PORT || '3000', 10);
const pool = new Pool({
host: process.env.POSTGRES_HOST || 'db',
port: parseInt(process.env.POSTGRES_PORT || '5432', 10),
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
max: 5,
});
const app = express();
app.use(express.json({ limit: '64kb' }));
app.get('/health', async (_req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'ok' });
} catch (err) {
res.status(503).json({ status: 'degraded', error: err.message });
}
});
app.get('/api/expenses', async (req, res, next) => {
try {
const limit = Math.min(parseInt(req.query.limit || '200', 10), 500);
const result = await pool.query(
`SELECT id, amount, category, note, spent_at, created_at
FROM expenses
ORDER BY spent_at DESC, created_at DESC
LIMIT $1`,
[limit]
);
res.json(result.rows);
} catch (err) { next(err); }
});
app.get('/api/expenses/summary', async (_req, res, next) => {
try {
const result = await pool.query(
`SELECT to_char(date_trunc('month', spent_at), 'YYYY-MM') AS month,
SUM(amount)::numeric(14,2) AS total,
COUNT(*)::int AS count
FROM expenses
GROUP BY 1
ORDER BY 1 DESC
LIMIT 12`
);
res.json(result.rows);
} catch (err) { next(err); }
});
app.post('/api/expenses', async (req, res, next) => {
try {
const { amount, category, note, spent_at } = req.body || {};
if (typeof amount !== 'number' || !isFinite(amount)) {
return res.status(400).json({ error: 'amount must be a number' });
}
if (typeof category !== 'string' || category.trim().length === 0 || category.length > 64) {
return res.status(400).json({ error: 'category required (max 64 chars)' });
}
if (note != null && (typeof note !== 'string' || note.length > 500)) {
return res.status(400).json({ error: 'note max 500 chars' });
}
if (typeof spent_at !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(spent_at)) {
return res.status(400).json({ error: 'spent_at must be YYYY-MM-DD' });
}
const result = await pool.query(
`INSERT INTO expenses (amount, category, note, spent_at)
VALUES ($1, $2, $3, $4)
RETURNING id, amount, category, note, spent_at, created_at`,
[amount, category.trim(), note ?? null, spent_at]
);
res.status(201).json(result.rows[0]);
} catch (err) { next(err); }
});
app.delete('/api/expenses/:id', async (req, res, next) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'invalid id' });
}
const result = await pool.query('DELETE FROM expenses WHERE id = $1', [id]);
if (result.rowCount === 0) return res.status(404).json({ error: 'not found' });
res.status(204).end();
} catch (err) { next(err); }
});
app.use((err, _req, res, _next) => {
console.error('[backend] error:', err);
res.status(500).json({ error: 'internal error' });
});
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`[backend] listening on :${PORT}`);
});
const shutdown = (signal) => {
console.log(`[backend] ${signal} received, shutting down`);
server.close(() => pool.end().then(() => process.exit(0)));
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

89
docker-compose.yml Normal file
View File

@ -0,0 +1,89 @@
name: sk1
services:
db:
image: postgres:16-alpine
container_name: sk1-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- appnet
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
backend:
build:
context: ./backend
dockerfile: Dockerfile
image: sk1-backend:latest
container_name: sk1-backend
restart: unless-stopped
environment:
PORT: "3000"
POSTGRES_HOST: db
POSTGRES_PORT: "5432"
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
depends_on:
db:
condition: service_healthy
networks:
- appnet
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
image: sk1-frontend:latest
container_name: sk1-frontend
restart: unless-stopped
networks:
- appnet
depends_on:
- backend
caddy:
image: caddy:2-alpine
container_name: sk1-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
DUCKDNS_DOMAIN: ${DUCKDNS_DOMAIN}
LE_EMAIL: ${LE_EMAIL}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
- ./logs/caddy:/var/log/caddy
networks:
- appnet
depends_on:
- frontend
- backend
networks:
appnet:
driver: bridge
volumes:
postgres_data:
caddy_data:
caddy_config:

63
docs/duckdns-setup.md Normal file
View File

@ -0,0 +1,63 @@
# DuckDNS Setup (for HTTPS on sk1)
## What DuckDNS is
DuckDNS is a **free, donation-funded dynamic DNS service**. It gives you a subdomain under `duckdns.org` and an HTTP API to point it at any IP.
| Feature | Free? |
|---|---|
| 5 subdomains per account | ✅ |
| Unlimited updates via HTTP API | ✅ |
| IPv4 and IPv6 records | ✅ |
| Custom domains | ❌ (use a registrar instead) |
| Email / MX records | ❌ |
| Premium tier | does not exist |
No credit card. No expiration. Forever free.
## Why this project uses it
`prepare-app.sh` needs to point a DNS name at the new VM's public IP **before** Caddy starts, so Let's Encrypt's HTTP-01 challenge succeeds on the first try. DuckDNS is the simplest free option that has a scriptable update URL.
## One-time setup (90 seconds)
1. Go to **https://www.duckdns.org/**
2. Click **"sign in with GitHub"** (or Google / Twitter / Reddit)
3. In the **"domains"** box, type a name (e.g. `gigi-expenses`) and click **"add domain"**
→ your full hostname is now `gigi-expenses.duckdns.org`
4. At the top of the page, copy the **token** (a UUID like `abcd1234-…`)
## Put it in `sk1/.env`
```bash
DUCKDNS_DOMAIN=gigi-expenses.duckdns.org
DUCKDNS_TOKEN=abcd1234-aaaa-bbbb-cccc-1234567890ab
LE_EMAIL=you@example.com
```
That's it. `prepare-app.sh` will call the DuckDNS update API with these values during the deploy.
## Manual test (optional)
To verify the token works before running `prepare-app.sh`:
```bash
curl "https://www.duckdns.org/update?domains=gigi-expenses&token=YOUR_TOKEN&ip="
# should print: OK
```
(Leaving `ip=` empty makes DuckDNS auto-detect your current IP — useful for the sanity check; the real deploy passes the VM's IP explicitly.)
## Troubleshooting
| Response | Meaning | Fix |
|---|---|---|
| `OK` | Worked | — |
| `KO` | Wrong token or domain | Double-check both values; tokens are case-sensitive |
| timeout | Network/DNS issue on your side | Try again, or use a different DNS resolver |
## Limits worth knowing
- **DNS propagation:** usually < 30 seconds globally; sometimes up to a few minutes
- **Let's Encrypt rate limit:** 50 certs / week / registered domain; not a concern here since you'll only issue 1
- **DuckDNS API rate limit:** generous, not documented; nowhere near the few calls `prepare-app.sh` makes

6
frontend/Dockerfile Normal file
View File

@ -0,0 +1,6 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY dist /usr/share/nginx/html
EXPOSE 80

15
frontend/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Expense Tracker</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

17
frontend/nginx.conf Normal file
View File

@ -0,0 +1,17 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Long-cache the immutable build assets
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# SPA fallback every other path serves index.html
location / {
try_files $uri $uri/ /index.html;
}
}

2687
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "expense-tracker-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 4173"
},
"dependencies": {
"lucide-react": "^0.453.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

229
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,229 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, Wallet, TrendingDown, Loader2, AlertCircle } from 'lucide-react';
import { api, Expense, MonthlySummary } from './lib/api';
const CATEGORIES = ['Food', 'Transport', 'Housing', 'Bills', 'Entertainment', 'Shopping', 'Health', 'Other'];
function todayISO() {
return new Date().toISOString().slice(0, 10);
}
function formatCurrency(n: number) {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(n);
}
export default function App() {
const [expenses, setExpenses] = useState<Expense[]>([]);
const [summary, setSummary] = useState<MonthlySummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [amount, setAmount] = useState('');
const [category, setCategory] = useState(CATEGORIES[0]);
const [note, setNote] = useState('');
const [spentAt, setSpentAt] = useState(todayISO());
const [submitting, setSubmitting] = useState(false);
async function refresh() {
setError(null);
try {
const [list, sum] = await Promise.all([api.list(), api.summary()]);
setExpenses(list);
setSummary(sum);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}
useEffect(() => { refresh(); }, []);
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []);
const currentMonthTotal = useMemo(() => {
const row = summary.find(s => s.month === currentMonth);
return row ? Number(row.total) : 0;
}, [summary, currentMonth]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!amount) return;
setSubmitting(true);
setError(null);
try {
await api.create({
amount: parseFloat(amount),
category,
note: note.trim() || null,
spent_at: spentAt,
});
setAmount('');
setNote('');
setSpentAt(todayISO());
await refresh();
} catch (e) {
setError((e as Error).message);
} finally {
setSubmitting(false);
}
}
async function handleDelete(id: number) {
setError(null);
try {
await api.remove(id);
await refresh();
} catch (e) {
setError((e as Error).message);
}
}
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:py-12">
<header className="mb-8 flex items-center gap-3">
<div className="rounded-xl bg-indigo-600 p-2.5 text-white shadow-sm">
<Wallet className="h-6 w-6" />
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Expense Tracker</h1>
<p className="text-sm text-slate-500">Track where your money goes.</p>
</div>
</header>
<section className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
<Stat
label="This month"
value={formatCurrency(currentMonthTotal)}
icon={<TrendingDown className="h-4 w-4 text-rose-500" />}
/>
<Stat label="Entries" value={String(expenses.length)} />
<Stat
label="Categories"
value={String(new Set(expenses.map(e => e.category)).size)}
/>
</section>
<section className="mb-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-medium text-slate-700">Add expense</h2>
<form onSubmit={handleSubmit} className="grid gap-3 sm:grid-cols-12">
<div className="sm:col-span-3">
<label className="mb-1 block text-xs font-medium text-slate-600">Amount</label>
<input
type="number"
step="0.01"
min="0"
required
value={amount}
onChange={e => setAmount(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-100"
placeholder="0.00"
/>
</div>
<div className="sm:col-span-3">
<label className="mb-1 block text-xs font-medium text-slate-600">Category</label>
<select
value={category}
onChange={e => setCategory(e.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-100"
>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="sm:col-span-3">
<label className="mb-1 block text-xs font-medium text-slate-600">Date</label>
<input
type="date"
required
value={spentAt}
onChange={e => setSpentAt(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-100"
/>
</div>
<div className="sm:col-span-3">
<label className="mb-1 block text-xs font-medium text-slate-600">&nbsp;</label>
<button
type="submit"
disabled={submitting}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-300 disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
Add
</button>
</div>
<div className="sm:col-span-12">
<label className="mb-1 block text-xs font-medium text-slate-600">Note (optional)</label>
<input
type="text"
maxLength={500}
value={note}
onChange={e => setNote(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-100"
placeholder="e.g. Groceries at Lidl"
/>
</div>
</form>
</section>
{error && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<section className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 px-5 py-3 text-sm font-medium text-slate-700">
Recent expenses
</div>
{loading ? (
<div className="flex items-center justify-center px-5 py-12 text-slate-400">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : expenses.length === 0 ? (
<div className="px-5 py-12 text-center text-sm text-slate-400">
No expenses yet. Add your first one above.
</div>
) : (
<ul className="divide-y divide-slate-100">
{expenses.map(e => (
<li key={e.id} className="flex items-center gap-4 px-5 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="font-medium text-slate-900">{formatCurrency(Number(e.amount))}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700">
{e.category}
</span>
<span className="text-xs text-slate-400">{e.spent_at}</span>
</div>
{e.note && <p className="mt-0.5 truncate text-sm text-slate-500">{e.note}</p>}
</div>
<button
onClick={() => handleDelete(e.id)}
className="rounded-lg p-2 text-slate-400 transition hover:bg-rose-50 hover:text-rose-600"
aria-label="Delete expense"
>
<Trash2 className="h-4 w-4" />
</button>
</li>
))}
</ul>
)}
</section>
<footer className="mt-10 text-center text-xs text-slate-400">
Deployed on Oracle Cloud Always Free tier · HTTPS via Caddy + Let's Encrypt
</footer>
</div>
);
}
function Stat({ label, value, icon }: { label: string; value: string; icon?: React.ReactNode }) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-slate-500">
{icon}{label}
</div>
<div className="mt-1 text-lg font-semibold text-slate-900">{value}</div>
</div>
);
}

39
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,39 @@
export type Expense = {
id: number;
amount: number;
category: string;
note: string | null;
spent_at: string;
created_at: string;
};
export type MonthlySummary = {
month: string;
total: number;
count: number;
};
const API_BASE = '/api';
async function jsonOrThrow<T>(res: Response): Promise<T> {
if (!res.ok) {
let detail = '';
try { detail = (await res.json()).error ?? ''; } catch { /* ignore */ }
throw new Error(`${res.status} ${res.statusText}${detail ? `${detail}` : ''}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
list: () => fetch(`${API_BASE}/expenses`).then(r => jsonOrThrow<Expense[]>(r)),
summary: () => fetch(`${API_BASE}/expenses/summary`).then(r => jsonOrThrow<MonthlySummary[]>(r)),
create: (e: Omit<Expense, 'id' | 'created_at'>) =>
fetch(`${API_BASE}/expenses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(e),
}).then(r => jsonOrThrow<Expense>(r)),
remove: (id: number) =>
fetch(`${API_BASE}/expenses/${id}`, { method: 'DELETE' }).then(r => jsonOrThrow<void>(r)),
};

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

16
frontend/src/styles.css Normal file
View File

@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
html, body, #root {
min-height: 100%;
}
body {
@apply bg-slate-50 text-slate-900 antialiased;
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
}

View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};

18
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}

16
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
sourcemap: false,
},
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000',
},
},
});

287
prepare-app.sh Normal file
View File

@ -0,0 +1,287 @@
#!/usr/bin/env bash
# =============================================================================
# prepare-app.sh — provision OCI A1.Flex VM and deploy the expense tracker
#
# Runs on the user's laptop. Reads ./.env. Never touches existing VMs.
# Idempotent: re-running checks current state before acting.
# =============================================================================
set -euo pipefail
# ---- Locate this script's dir (works on macOS / Linux / WSL) ----
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ---- Load .env ----
if [ ! -f .env ]; then
echo "ERROR: .env not found in $SCRIPT_DIR" >&2
echo "Copy .env.example to .env and fill in real values." >&2
exit 1
fi
set -a; source .env; set +a
# ---- Required vars ----
: "${DUCKDNS_DOMAIN:?DUCKDNS_DOMAIN must be set in .env}"
: "${DUCKDNS_TOKEN:?DUCKDNS_TOKEN must be set in .env}"
: "${LE_EMAIL:?LE_EMAIL must be set in .env}"
: "${POSTGRES_USER:?POSTGRES_USER must be set in .env}"
: "${POSTGRES_DB:?POSTGRES_DB must be set in .env}"
: "${OCI_VM_NAME:=sk1-expense-tracker}"
: "${OCI_AVAILABILITY_DOMAIN:=Cekz:EU-ZURICH-1-AD-1}"
: "${OCI_SSH_PUBLIC_KEY_PATH:=$HOME/.ssh/id_ed25519.pub}"
: "${OCI_SSH_PRIVATE_KEY_PATH:=$HOME/.ssh/id_ed25519}"
# ---- Expand ~ in paths ----
OCI_SSH_PUBLIC_KEY_PATH="${OCI_SSH_PUBLIC_KEY_PATH/#\~/$HOME}"
OCI_SSH_PRIVATE_KEY_PATH="${OCI_SSH_PRIVATE_KEY_PATH/#\~/$HOME}"
# ---- Tooling check ----
command -v oci >/dev/null || { echo "ERROR: oci CLI not installed"; exit 1; }
command -v ssh >/dev/null || { echo "ERROR: ssh not installed"; exit 1; }
command -v scp >/dev/null || { echo "ERROR: scp not installed"; exit 1; }
command -v curl >/dev/null || { echo "ERROR: curl not installed"; exit 1; }
[ -f "$OCI_SSH_PUBLIC_KEY_PATH" ] || { echo "ERROR: SSH public key not found at $OCI_SSH_PUBLIC_KEY_PATH"; exit 1; }
[ -f "$OCI_SSH_PRIVATE_KEY_PATH" ] || { echo "ERROR: SSH private key not found at $OCI_SSH_PRIVATE_KEY_PATH"; exit 1; }
# ---- Auto-generate POSTGRES_PASSWORD if blank ----
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
GEN_PW="$(openssl rand -base64 24 | tr -d '/+=' | head -c 28)"
POSTGRES_PASSWORD="$GEN_PW"
# Persist generated password in .env
if grep -q '^POSTGRES_PASSWORD=' .env; then
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${POSTGRES_PASSWORD}|" .env
else
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${POSTGRES_PASSWORD}|" .env
fi
else
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" >> .env
fi
echo "[prepare] generated POSTGRES_PASSWORD and saved to .env"
fi
export POSTGRES_PASSWORD
# ---- Resolve compartment ----
# Prefer reading tenancy OCID from ~/.oci/config (works even with zero sub-compartments).
TENANCY_OCID="$(awk -F= '/^tenancy[[:space:]]*=/{gsub(/[[:space:]]/,"",$2); print $2; exit}' "${HOME}/.oci/config" 2>/dev/null || true)"
if [ -z "$TENANCY_OCID" ]; then
TENANCY_OCID="$(oci iam compartment list --query 'data[0]."compartment-id"' --raw-output 2>/dev/null || true)"
fi
COMPARTMENT_ID="${OCI_COMPARTMENT_ID:-$TENANCY_OCID}"
[ -n "$COMPARTMENT_ID" ] && [ "$COMPARTMENT_ID" != "null" ] || { echo "ERROR: could not resolve compartment OCID"; exit 1; }
echo "[prepare] using compartment: $COMPARTMENT_ID"
# ---- Resolve latest Ubuntu 22.04 x86_64 image ----
echo "[prepare] resolving latest Ubuntu 22.04 x86_64 image..."
IMAGE_ID="$(oci compute image list \
--compartment-id "$COMPARTMENT_ID" \
--operating-system "Canonical Ubuntu" \
--operating-system-version "22.04" \
--shape "VM.Standard.E2.1.Micro" \
--sort-by TIMECREATED --sort-order DESC \
--query 'data[?!(contains("display-name", `Minimal`) || contains("display-name", `GPU`))] | [0].id' --raw-output)"
[ -n "$IMAGE_ID" ] && [ "$IMAGE_ID" != "null" ] || { echo "ERROR: failed to resolve Ubuntu image"; exit 1; }
# ---- Ensure VCN / subnet / IG / security list exist (idempotent) ----
VCN_NAME="sk1-vcn"
SUBNET_NAME="sk1-subnet"
IG_NAME="sk1-ig"
SL_NAME="sk1-seclist"
RT_NAME="sk1-rt"
VCN_ID="$(oci network vcn list --compartment-id "$COMPARTMENT_ID" \
--display-name "$VCN_NAME" --query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -z "$VCN_ID" ] || [ "$VCN_ID" = "null" ]; then
echo "[prepare] creating VCN $VCN_NAME..."
VCN_ID="$(oci network vcn create \
--compartment-id "$COMPARTMENT_ID" \
--display-name "$VCN_NAME" \
--cidr-block "10.10.0.0/16" \
--dns-label "sk1vcn" \
--wait-for-state AVAILABLE \
--query 'data.id' --raw-output)"
else
echo "[prepare] reusing existing VCN $VCN_NAME ($VCN_ID)"
fi
IG_ID="$(oci network internet-gateway list --compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$IG_NAME" --query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -z "$IG_ID" ] || [ "$IG_ID" = "null" ]; then
echo "[prepare] creating internet gateway..."
IG_ID="$(oci network internet-gateway create \
--compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$IG_NAME" --is-enabled true \
--wait-for-state AVAILABLE --query 'data.id' --raw-output)"
fi
RT_ID="$(oci network route-table list --compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$RT_NAME" --query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -z "$RT_ID" ] || [ "$RT_ID" = "null" ]; then
echo "[prepare] creating route table..."
RT_ID="$(oci network route-table create \
--compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$RT_NAME" \
--route-rules "[{\"destination\":\"0.0.0.0/0\",\"destinationType\":\"CIDR_BLOCK\",\"networkEntityId\":\"$IG_ID\"}]" \
--wait-for-state AVAILABLE --query 'data.id' --raw-output)"
fi
SL_ID="$(oci network security-list list --compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$SL_NAME" --query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -z "$SL_ID" ] || [ "$SL_ID" = "null" ]; then
echo "[prepare] creating security list..."
INGRESS_RULES='[
{"protocol":"6","source":"0.0.0.0/0","sourceType":"CIDR_BLOCK","isStateless":false,"tcpOptions":{"destinationPortRange":{"min":22,"max":22}}},
{"protocol":"6","source":"0.0.0.0/0","sourceType":"CIDR_BLOCK","isStateless":false,"tcpOptions":{"destinationPortRange":{"min":80,"max":80}}},
{"protocol":"6","source":"0.0.0.0/0","sourceType":"CIDR_BLOCK","isStateless":false,"tcpOptions":{"destinationPortRange":{"min":443,"max":443}}}
]'
EGRESS_RULES='[{"protocol":"all","destination":"0.0.0.0/0","destinationType":"CIDR_BLOCK","isStateless":false}]'
SL_ID="$(oci network security-list create \
--compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$SL_NAME" \
--ingress-security-rules "$INGRESS_RULES" \
--egress-security-rules "$EGRESS_RULES" \
--wait-for-state AVAILABLE --query 'data.id' --raw-output)"
fi
SUBNET_ID="$(oci network subnet list --compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$SUBNET_NAME" --query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -z "$SUBNET_ID" ] || [ "$SUBNET_ID" = "null" ]; then
echo "[prepare] creating subnet..."
SUBNET_ID="$(oci network subnet create \
--compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$SUBNET_NAME" \
--cidr-block "10.10.1.0/24" \
--availability-domain "$OCI_AVAILABILITY_DOMAIN" \
--route-table-id "$RT_ID" \
--security-list-ids "[\"$SL_ID\"]" \
--dns-label "sk1sub" \
--wait-for-state AVAILABLE --query 'data.id' --raw-output)"
fi
# ---- Launch the VM (idempotent: check by display-name first) ----
INSTANCE_ID="$(oci compute instance list --compartment-id "$COMPARTMENT_ID" \
--display-name "$OCI_VM_NAME" \
--lifecycle-state RUNNING \
--query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" = "null" ]; then
echo "[prepare] launching new VM '$OCI_VM_NAME' (E2.1.Micro, 1 OCPU / 1 GB / 50 GB)..."
INSTANCE_ID="$(oci compute instance launch \
--compartment-id "$COMPARTMENT_ID" \
--availability-domain "$OCI_AVAILABILITY_DOMAIN" \
--shape "VM.Standard.E2.1.Micro" \
--image-id "$IMAGE_ID" \
--subnet-id "$SUBNET_ID" \
--display-name "$OCI_VM_NAME" \
--boot-volume-size-in-gbs 50 \
--ssh-authorized-keys-file "$OCI_SSH_PUBLIC_KEY_PATH" \
--assign-public-ip true \
--wait-for-state RUNNING \
--query 'data.id' --raw-output)"
else
echo "[prepare] reusing existing VM ($INSTANCE_ID)"
fi
# ---- Get public IP ----
echo "[prepare] resolving public IP..."
VNIC_ID="$(oci compute instance list-vnics --instance-id "$INSTANCE_ID" \
--query 'data[0].id' --raw-output)"
PUBLIC_IP="$(oci network vnic get --vnic-id "$VNIC_ID" \
--query 'data."public-ip"' --raw-output)"
[ -n "$PUBLIC_IP" ] && [ "$PUBLIC_IP" != "null" ] || { echo "ERROR: no public IP"; exit 1; }
echo "[prepare] VM public IP: $PUBLIC_IP"
# ---- Update DuckDNS BEFORE starting Caddy so cert issuance works ----
echo "[prepare] updating DuckDNS $DUCKDNS_DOMAIN$PUBLIC_IP..."
DUCK_RESP="$(curl -fsS "https://www.duckdns.org/update?domains=${DUCKDNS_DOMAIN%%.duckdns.org}&token=${DUCKDNS_TOKEN}&ip=${PUBLIC_IP}")"
[ "$DUCK_RESP" = "OK" ] || { echo "ERROR: DuckDNS update failed: $DUCK_RESP"; exit 1; }
# ---- Wait for SSH ----
echo "[prepare] waiting for SSH on $PUBLIC_IP..."
for i in {1..30}; do
if ssh -i "$OCI_SSH_PRIVATE_KEY_PATH" -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
-o UserKnownHostsFile=/dev/null ubuntu@"$PUBLIC_IP" "echo ready" >/dev/null 2>&1; then
echo "[prepare] SSH ready"; break
fi
echo " ($i/30) not yet, retrying in 10s..."
sleep 10
if [ "$i" -eq 30 ]; then echo "ERROR: SSH never came up"; exit 1; fi
done
SSH_OPTS=(-i "$OCI_SSH_PRIVATE_KEY_PATH" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
SCP_OPTS=(-i "$OCI_SSH_PRIVATE_KEY_PATH" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
# ---- Bootstrap VM: Docker, ufw rules, app dir ----
echo "[prepare] bootstrapping VM (Docker, firewall, ports)..."
ssh "${SSH_OPTS[@]}" ubuntu@"$PUBLIC_IP" 'bash -s' << 'REMOTE'
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
# 2 GB swap so Docker installs and Postgres init don't OOM on the 1 GB Micro shape.
if [ ! -f /swapfile ]; then
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile >/dev/null
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab >/dev/null
fi
if ! command -v docker >/dev/null; then
sudo apt-get update -y
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update -y
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker ubuntu
fi
# Ubuntu OCI images have iptables DROP rules by default — open 80/443 (idempotent).
sudo iptables -C INPUT -p tcp --dport 80 -j ACCEPT 2>/dev/null || sudo iptables -I INPUT 6 -p tcp --dport 80 -j ACCEPT
sudo iptables -C INPUT -p tcp --dport 443 -j ACCEPT 2>/dev/null || sudo iptables -I INPUT 6 -p tcp --dport 443 -j ACCEPT
sudo netfilter-persistent save || sudo iptables-save | sudo tee /etc/iptables/rules.v4 >/dev/null
sudo mkdir -p /opt/sk1
sudo chown -R ubuntu:ubuntu /opt/sk1
REMOTE
# ---- Build the frontend locally (the E2.1.Micro VM has too little RAM for Vite) ----
echo "[prepare] building frontend bundle locally..."
( cd frontend && npm install --no-audit --no-fund --silent && npm run build )
[ -d frontend/dist ] && [ -f frontend/dist/index.html ] || { echo "ERROR: frontend build did not produce dist/"; exit 1; }
# ---- Sync project files ----
echo "[prepare] uploading project files..."
# Wipe app files but preserve logs/ and backups/ so historical access logs and dumps survive re-deploys.
ssh "${SSH_OPTS[@]}" ubuntu@"$PUBLIC_IP" "find /opt/sk1 -mindepth 1 -maxdepth 1 ! -name logs ! -name backups -exec rm -rf {} +; mkdir -p /opt/sk1/logs/caddy /opt/sk1/backups"
# tar up only the files we need — ship the pre-built dist, leave node_modules behind.
# Portable across GNU and BSD mktemp: create, then rename to add the .tar.gz suffix.
TMP_BASE="$(mktemp -t sk1.XXXXXX)"
TMP_TAR="${TMP_BASE}.tar.gz"
mv "$TMP_BASE" "$TMP_TAR"
tar -czf "$TMP_TAR" \
--exclude='node_modules' --exclude='backups' --exclude='logs' --exclude='.git' \
docker-compose.yml Caddyfile .env .env.example .gitignore \
frontend backend scripts
scp "${SCP_OPTS[@]}" "$TMP_TAR" ubuntu@"$PUBLIC_IP":/tmp/sk1.tar.gz
rm -f "$TMP_TAR"
ssh "${SSH_OPTS[@]}" ubuntu@"$PUBLIC_IP" "tar -xzf /tmp/sk1.tar.gz -C /opt/sk1 && rm /tmp/sk1.tar.gz"
# ---- Bring up the stack ----
echo "[prepare] starting Docker Compose..."
ssh "${SSH_OPTS[@]}" ubuntu@"$PUBLIC_IP" 'cd /opt/sk1 && sg docker -c "docker compose up -d --build --remove-orphans"'
echo ""
echo "================================================================"
echo " Deployed!"
echo ""
echo " URL: https://${DUCKDNS_DOMAIN}"
echo " VM IP: ${PUBLIC_IP}"
echo " SSH: ssh -i ${OCI_SSH_PRIVATE_KEY_PATH} ubuntu@${PUBLIC_IP}"
echo ""
echo " First request may take ~30s while Caddy issues the cert."
echo " Tear down: ./remove-app.sh"
echo "================================================================"

109
remove-app.sh Normal file
View File

@ -0,0 +1,109 @@
#!/usr/bin/env bash
# =============================================================================
# remove-app.sh — tear down everything created by prepare-app.sh.
#
# Does NOT touch any existing resources outside sk1-* and the named VM.
# Idempotent: safe to re-run; each step skips if already gone.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
if [ ! -f .env ]; then
echo "ERROR: .env not found in $SCRIPT_DIR" >&2
exit 1
fi
set -a; source .env; set +a
: "${OCI_VM_NAME:=sk1-expense-tracker}"
: "${OCI_SSH_PRIVATE_KEY_PATH:=$HOME/.ssh/id_ed25519}"
OCI_SSH_PRIVATE_KEY_PATH="${OCI_SSH_PRIVATE_KEY_PATH/#\~/$HOME}"
command -v oci >/dev/null || { echo "ERROR: oci CLI not installed"; exit 1; }
TENANCY_OCID="$(oci iam compartment list --query 'data[0]."compartment-id"' --raw-output 2>/dev/null)"
COMPARTMENT_ID="${OCI_COMPARTMENT_ID:-$TENANCY_OCID}"
VCN_NAME="sk1-vcn"
SUBNET_NAME="sk1-subnet"
IG_NAME="sk1-ig"
SL_NAME="sk1-seclist"
RT_NAME="sk1-rt"
# ---- Find the VM by name ----
INSTANCE_ID="$(oci compute instance list --compartment-id "$COMPARTMENT_ID" \
--display-name "$OCI_VM_NAME" \
--query 'data[?"lifecycle-state" != `TERMINATED`] | [0].id' --raw-output 2>/dev/null || true)"
if [ -n "$INSTANCE_ID" ] && [ "$INSTANCE_ID" != "null" ]; then
echo "[remove] tearing down Compose stack on the VM..."
VNIC_ID="$(oci compute instance list-vnics --instance-id "$INSTANCE_ID" --query 'data[0].id' --raw-output 2>/dev/null || true)"
PUBLIC_IP="$(oci network vnic get --vnic-id "$VNIC_ID" --query 'data."public-ip"' --raw-output 2>/dev/null || true)"
if [ -n "$PUBLIC_IP" ] && [ "$PUBLIC_IP" != "null" ]; then
ssh -i "$OCI_SSH_PRIVATE_KEY_PATH" -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 \
ubuntu@"$PUBLIC_IP" \
'cd /opt/sk1 && sg docker -c "docker compose down -v --rmi local --remove-orphans" || true' \
|| echo "[remove] (could not reach VM via SSH — proceeding to terminate)"
fi
echo "[remove] terminating VM $OCI_VM_NAME ($INSTANCE_ID)..."
oci compute instance terminate --instance-id "$INSTANCE_ID" --force \
--preserve-boot-volume false --wait-for-state TERMINATED
else
echo "[remove] no running VM named $OCI_VM_NAME — skipping"
fi
# ---- Delete network resources (in reverse dependency order) ----
VCN_ID="$(oci network vcn list --compartment-id "$COMPARTMENT_ID" --display-name "$VCN_NAME" \
--query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -n "$VCN_ID" ] && [ "$VCN_ID" != "null" ]; then
SUBNET_ID="$(oci network subnet list --compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$SUBNET_NAME" --query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -n "$SUBNET_ID" ] && [ "$SUBNET_ID" != "null" ]; then
echo "[remove] deleting subnet..."
oci network subnet delete --subnet-id "$SUBNET_ID" --force --wait-for-state TERMINATED || true
fi
SL_ID="$(oci network security-list list --compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$SL_NAME" --query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -n "$SL_ID" ] && [ "$SL_ID" != "null" ]; then
echo "[remove] deleting security list..."
oci network security-list delete --security-list-id "$SL_ID" --force --wait-for-state TERMINATED || true
fi
RT_ID="$(oci network route-table list --compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$RT_NAME" --query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -n "$RT_ID" ] && [ "$RT_ID" != "null" ]; then
echo "[remove] deleting route table..."
oci network route-table delete --rt-id "$RT_ID" --force --wait-for-state TERMINATED || true
fi
IG_ID="$(oci network internet-gateway list --compartment-id "$COMPARTMENT_ID" --vcn-id "$VCN_ID" \
--display-name "$IG_NAME" --query 'data[0].id' --raw-output 2>/dev/null || true)"
if [ -n "$IG_ID" ] && [ "$IG_ID" != "null" ]; then
echo "[remove] deleting internet gateway..."
oci network internet-gateway delete --ig-id "$IG_ID" --force --wait-for-state TERMINATED || true
fi
echo "[remove] deleting VCN..."
oci network vcn delete --vcn-id "$VCN_ID" --force --wait-for-state TERMINATED || true
else
echo "[remove] no VCN named $VCN_NAME — skipping"
fi
# ---- Reset DuckDNS A record (best effort) ----
if [ -n "${DUCKDNS_DOMAIN:-}" ] && [ -n "${DUCKDNS_TOKEN:-}" ]; then
echo "[remove] clearing DuckDNS A record..."
curl -fsS "https://www.duckdns.org/update?domains=${DUCKDNS_DOMAIN%%.duckdns.org}&token=${DUCKDNS_TOKEN}&clear=true" \
|| echo "(DuckDNS clear failed — non-fatal)"
fi
echo ""
echo "================================================================"
echo " All sk1 resources removed. Existing VMs and unrelated"
echo " resources are untouched."
echo "================================================================"

46
scripts/backup.sh Normal file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
# =============================================================================
# backup.sh — dump the Postgres database to ./backups/expenses-<ts>.sql.gz
#
# Run from your laptop. Requires .env (for OCI_VM_NAME and SSH key) and
# uses the OCI CLI to look up the VM's public IP.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT_DIR"
[ -f .env ] || { echo "ERROR: .env not found"; exit 1; }
set -a; source .env; set +a
: "${OCI_VM_NAME:=sk1-expense-tracker}"
: "${POSTGRES_USER:?POSTGRES_USER must be set in .env}"
: "${POSTGRES_DB:?POSTGRES_DB must be set in .env}"
: "${OCI_SSH_PRIVATE_KEY_PATH:=$HOME/.ssh/id_ed25519}"
OCI_SSH_PRIVATE_KEY_PATH="${OCI_SSH_PRIVATE_KEY_PATH/#\~/$HOME}"
TENANCY_OCID="$(oci iam compartment list --query 'data[0]."compartment-id"' --raw-output 2>/dev/null)"
COMPARTMENT_ID="${OCI_COMPARTMENT_ID:-$TENANCY_OCID}"
INSTANCE_ID="$(oci compute instance list --compartment-id "$COMPARTMENT_ID" \
--display-name "$OCI_VM_NAME" --lifecycle-state RUNNING \
--query 'data[0].id' --raw-output)"
[ -n "$INSTANCE_ID" ] && [ "$INSTANCE_ID" != "null" ] || { echo "ERROR: VM not found"; exit 1; }
VNIC_ID="$(oci compute instance list-vnics --instance-id "$INSTANCE_ID" --query 'data[0].id' --raw-output)"
PUBLIC_IP="$(oci network vnic get --vnic-id "$VNIC_ID" --query 'data."public-ip"' --raw-output)"
mkdir -p backups
TS="$(date +%Y%m%d-%H%M%S)"
OUT="backups/expenses-${TS}.sql.gz"
echo "[backup] dumping $POSTGRES_DB from $PUBLIC_IP..."
ssh -i "$OCI_SSH_PRIVATE_KEY_PATH" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
ubuntu@"$PUBLIC_IP" \
"sg docker -c 'docker exec sk1-db pg_dump -U $POSTGRES_USER -Fc $POSTGRES_DB'" \
| gzip > "$OUT"
SIZE="$(du -h "$OUT" | cut -f1)"
echo "[backup] saved $OUT ($SIZE)"

42
scripts/restore.sh Normal file
View File

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# =============================================================================
# restore.sh <backup-file.sql.gz> — restore a backup to the live database.
# WARNING: drops existing data.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT_DIR"
[ "${1:-}" ] || { echo "Usage: $0 backups/expenses-YYYYMMDD-HHMMSS.sql.gz"; exit 1; }
FILE="$1"
[ -f "$FILE" ] || { echo "ERROR: file not found: $FILE"; exit 1; }
[ -f .env ] || { echo "ERROR: .env not found"; exit 1; }
set -a; source .env; set +a
: "${OCI_VM_NAME:=sk1-expense-tracker}"
: "${POSTGRES_USER:?POSTGRES_USER must be set}"
: "${POSTGRES_DB:?POSTGRES_DB must be set}"
: "${OCI_SSH_PRIVATE_KEY_PATH:=$HOME/.ssh/id_ed25519}"
OCI_SSH_PRIVATE_KEY_PATH="${OCI_SSH_PRIVATE_KEY_PATH/#\~/$HOME}"
TENANCY_OCID="$(oci iam compartment list --query 'data[0]."compartment-id"' --raw-output)"
COMPARTMENT_ID="${OCI_COMPARTMENT_ID:-$TENANCY_OCID}"
INSTANCE_ID="$(oci compute instance list --compartment-id "$COMPARTMENT_ID" \
--display-name "$OCI_VM_NAME" --lifecycle-state RUNNING \
--query 'data[0].id' --raw-output)"
VNIC_ID="$(oci compute instance list-vnics --instance-id "$INSTANCE_ID" --query 'data[0].id' --raw-output)"
PUBLIC_IP="$(oci network vnic get --vnic-id "$VNIC_ID" --query 'data."public-ip"' --raw-output)"
read -p "Restore $FILE$POSTGRES_DB on $PUBLIC_IP? This WIPES existing data. [y/N] " ans
[ "$ans" = "y" ] || [ "$ans" = "Y" ] || exit 1
echo "[restore] streaming dump..."
gunzip -c "$FILE" | ssh -i "$OCI_SSH_PRIVATE_KEY_PATH" -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null ubuntu@"$PUBLIC_IP" \
"sg docker -c 'docker exec -i sk1-db pg_restore -U $POSTGRES_USER -d $POSTGRES_DB --clean --if-exists'"
echo "[restore] done."

41
scripts/view-logs.sh Normal file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# =============================================================================
# view-logs.sh — tail Caddy access logs from the VM.
#
# Usage:
# ./scripts/view-logs.sh # tail -f the access log
# ./scripts/view-logs.sh recent # last 50 entries as pretty JSON
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT_DIR"
[ -f .env ] || { echo "ERROR: .env not found"; exit 1; }
set -a; source .env; set +a
: "${OCI_VM_NAME:=sk1-expense-tracker}"
: "${OCI_SSH_PRIVATE_KEY_PATH:=$HOME/.ssh/id_ed25519}"
OCI_SSH_PRIVATE_KEY_PATH="${OCI_SSH_PRIVATE_KEY_PATH/#\~/$HOME}"
TENANCY_OCID="$(oci iam compartment list --query 'data[0]."compartment-id"' --raw-output)"
COMPARTMENT_ID="${OCI_COMPARTMENT_ID:-$TENANCY_OCID}"
INSTANCE_ID="$(oci compute instance list --compartment-id "$COMPARTMENT_ID" \
--display-name "$OCI_VM_NAME" --lifecycle-state RUNNING \
--query 'data[0].id' --raw-output)"
VNIC_ID="$(oci compute instance list-vnics --instance-id "$INSTANCE_ID" --query 'data[0].id' --raw-output)"
PUBLIC_IP="$(oci network vnic get --vnic-id "$VNIC_ID" --query 'data."public-ip"' --raw-output)"
MODE="${1:-tail}"
case "$MODE" in
recent)
ssh -i "$OCI_SSH_PRIVATE_KEY_PATH" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
ubuntu@"$PUBLIC_IP" 'tail -n 50 /opt/sk1/logs/caddy/access.log | (command -v jq >/dev/null && jq . || cat)'
;;
tail|*)
ssh -i "$OCI_SSH_PRIVATE_KEY_PATH" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
ubuntu@"$PUBLIC_IP" 'tail -f /opt/sk1/logs/caddy/access.log'
;;
esac