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