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
288 lines
13 KiB
Bash
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 "================================================================"
|