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:
commit
260b60622f
28
.env.example
Normal file
28
.env.example
Normal 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
8
.gitattributes
vendored
Normal 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
28
.gitignore
vendored
Normal 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
31
Caddyfile
Normal 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
326
README.md
Normal 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
16
backend/Dockerfile
Normal 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
13
backend/db/init.sql
Normal 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
18
backend/package.json
Normal 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
109
backend/src/index.js
Normal 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
89
docker-compose.yml
Normal 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
63
docs/duckdns-setup.md
Normal 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
6
frontend/Dockerfile
Normal 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
15
frontend/index.html
Normal 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
17
frontend/nginx.conf
Normal 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
2687
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
229
frontend/src/App.tsx
Normal file
229
frontend/src/App.tsx
Normal 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"> </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
39
frontend/src/lib/api.ts
Normal 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
10
frontend/src/main.tsx
Normal 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
16
frontend/src/styles.css
Normal 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;
|
||||
}
|
||||
12
frontend/tailwind.config.js
Normal file
12
frontend/tailwind.config.js
Normal 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
18
frontend/tsconfig.json
Normal 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
16
frontend/vite.config.ts
Normal 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
287
prepare-app.sh
Normal 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
109
remove-app.sh
Normal 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
46
scripts/backup.sh
Normal 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
42
scripts/restore.sh
Normal 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
41
scripts/view-logs.sh
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user