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

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

288 lines
13 KiB
Bash

#!/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 "================================================================"