Files
etcprs/scripts/deploy.sh
2026-03-18 03:06:27 -06:00

390 lines
16 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# =============================================================================
# ETC PRS — Initial Deployment Script
# Targets: Ubuntu 24.04 LTS on a fresh Digital Ocean droplet
# Usage: sudo bash deploy.sh
# =============================================================================
set -euo pipefail
# ── Colours ───────────────────────────────────────────────────────────────────
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${CYAN}$*${RESET}"; }
success() { echo -e "${GREEN}$*${RESET}"; }
warn() { echo -e "${YELLOW}$*${RESET}"; }
error() { echo -e "${RED}$*${RESET}"; exit 1; }
header() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; }
divider() { echo -e "${CYAN}─────────────────────────────────────────────────${RESET}"; }
# ── Root check ────────────────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (sudo bash deploy.sh)"
fi
# ── Banner ────────────────────────────────────────────────────────────────────
clear
echo -e "${BOLD}${CYAN}"
echo " ███████╗████████╗ ██████╗ ██████╗ ██████╗ ███████╗"
echo " ██╔════╝╚══██╔══╝██╔════╝ ██╔══██╗██╔══██╗██╔════╝"
echo " █████╗ ██║ ██║ ██████╔╝██████╔╝███████╗"
echo " ██╔══╝ ██║ ██║ ██╔═══╝ ██╔══██╗╚════██║"
echo " ███████╗ ██║ ╚██████╗ ██║ ██║ ██║███████║"
echo " ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝"
echo -e "${RESET}"
echo -e " ${BOLD}Personality Editor — Initial Deployment${RESET}"
echo -e " ${CYAN}Targeting Ubuntu 24.04 · Node 20 · PM2 · Nginx · Certbot${RESET}"
divider
echo ""
# ── Interactive prompts ───────────────────────────────────────────────────────
header "Configuration"
# Domain
while true; do
read -rp "$(echo -e "${BOLD}Domain name${RESET} (e.g. etcprs.app): ")" DOMAIN
DOMAIN="${DOMAIN#www.}" # strip leading www. if provided
DOMAIN="${DOMAIN%/}" # strip trailing slash
[[ -n "$DOMAIN" ]] && break
warn "Domain cannot be empty."
done
# Gitea repo
echo ""
read -rp "$(echo -e "${BOLD}Gitea repo path${RESET} [default: raine/etc-prs-ui]: ")" REPO_PATH
REPO_PATH="${REPO_PATH:-raine/etc-prs-ui}"
REPO_URL="https://git.etcprs.app/${REPO_PATH}.git"
echo -e " ${CYAN}${REPO_URL}${RESET}"
# Gitea credentials (for private repos)
echo ""
echo -e "${YELLOW}If your Gitea repo is private, enter credentials below."
echo -e "Leave blank if the repo is public.${RESET}"
read -rp "$(echo -e "${BOLD}Gitea username${RESET} (blank = public): ")" GITEA_USER
if [[ -n "$GITEA_USER" ]]; then
read -rsp "$(echo -e "${BOLD}Gitea password / token${RESET}: ")" GITEA_PASS
echo ""
REPO_URL="https://${GITEA_USER}:${GITEA_PASS}@git.etcprs.app/${REPO_PATH}.git"
fi
# Admin account
echo ""
echo -e "${BOLD}Create the first admin account:${RESET}"
while true; do
read -rp " Admin username (232 chars): " ADMIN_USER
[[ ${#ADMIN_USER} -ge 2 && ${#ADMIN_USER} -le 32 ]] && break
warn "Username must be 232 characters."
done
while true; do
read -rsp " Admin password (min 8 chars): " ADMIN_PASS
echo ""
[[ ${#ADMIN_PASS} -ge 8 ]] && break
warn "Password must be at least 8 characters."
done
read -rsp " Confirm password: " ADMIN_PASS_CONFIRM
echo ""
[[ "$ADMIN_PASS" == "$ADMIN_PASS_CONFIRM" ]] || error "Passwords do not match."
# Rate limits
echo ""
echo -e "${BOLD}Rate limits${RESET} (press Enter to accept defaults):"
read -rp " Publish rate limit per IP/hour [default: 5]: " RATE_PUBLISH
RATE_PUBLISH="${RATE_PUBLISH:-5}"
read -rp " Read rate limit per IP/hour [default: 100]: " RATE_READ
RATE_READ="${RATE_READ:-100}"
# SSL email
echo ""
read -rp "$(echo -e "${BOLD}Email for SSL certificate${RESET} (Let's Encrypt notices): ")" SSL_EMAIL
[[ -n "$SSL_EMAIL" ]] || error "SSL email is required."
# ── Confirmation ──────────────────────────────────────────────────────────────
echo ""
divider
echo -e "${BOLD} Deployment Summary${RESET}"
divider
echo -e " Domain: ${CYAN}${DOMAIN}${RESET} (and www.${DOMAIN})"
echo -e " Repo: ${CYAN}${REPO_URL//:*@/:***@}${RESET}"
echo -e " Admin user: ${CYAN}${ADMIN_USER}${RESET}"
echo -e " Rate limits: publish=${RATE_PUBLISH}/hr read=${RATE_READ}/hr"
echo -e " SSL email: ${CYAN}${SSL_EMAIL}${RESET}"
divider
echo ""
read -rp "$(echo -e "${BOLD}Proceed with deployment? [y/N]: ${RESET}")" CONFIRM
[[ "${CONFIRM,,}" == "y" ]] || { echo "Aborted."; exit 0; }
# ── Constants ─────────────────────────────────────────────────────────────────
APP_USER="prs"
APP_DIR="/opt/etc-prs/app"
DATA_DIR="/var/lib/etc-prs"
LOG_DIR="/var/log/etc-prs"
BACKUP_DIR="/var/backups/etc-prs"
BACKUP_SCRIPT="/opt/etc-prs/backup.sh"
NGINX_CONF="/etc/nginx/sites-available/etc-prs"
# ── Step 1: System packages ───────────────────────────────────────────────────
header "Step 1 / 9 — System Packages"
info "Updating package lists…"
apt-get update -qq
info "Upgrading installed packages…"
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
info "Installing build tools, Nginx, Certbot, SQLite…"
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
build-essential python3 git nginx certbot python3-certbot-nginx sqlite3 ufw curl
info "Installing Node.js 20 LTS via NodeSource…"
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs
NODE_VER=$(node --version)
NPM_VER=$(npm --version)
success "Node ${NODE_VER} / npm ${NPM_VER} installed"
info "Installing PM2 globally…"
npm install -g pm2 --quiet
success "PM2 $(pm2 --version) installed"
# ── Step 2: Users & directories ───────────────────────────────────────────────
header "Step 2 / 9 — Users & Directories"
if ! id "$APP_USER" &>/dev/null; then
info "Creating system user '${APP_USER}'…"
useradd --system --shell /bin/false --home /opt/etc-prs "$APP_USER"
success "User '${APP_USER}' created"
else
success "User '${APP_USER}' already exists"
fi
for DIR in /opt/etc-prs "$APP_DIR" "$DATA_DIR" "$LOG_DIR" "$BACKUP_DIR"; do
mkdir -p "$DIR"
done
chown -R "${APP_USER}:${APP_USER}" /opt/etc-prs "$DATA_DIR" "$LOG_DIR" "$BACKUP_DIR"
success "Directories created and ownership set"
# ── Step 3: Clone from Gitea ──────────────────────────────────────────────────
header "Step 3 / 9 — Clone Repository"
info "Cloning from ${REPO_URL//:*@/:***@}"
if [[ -d "${APP_DIR}/.git" ]]; then
warn "App directory already contains a git repo — pulling latest instead."
sudo -u "$APP_USER" git -C "$APP_DIR" pull
else
sudo -u "$APP_USER" git clone "$REPO_URL" "$APP_DIR" 2>&1 | \
sed 's/'"${GITEA_PASS:-NOPASS}"'/***REDACTED***/g' || \
error "Git clone failed. Check your repo URL and credentials."
fi
success "Repository cloned to ${APP_DIR}"
# ── Step 4: Environment file ──────────────────────────────────────────────────
header "Step 4 / 9 — Environment Configuration"
ENV_FILE="${APP_DIR}/.env"
cat > "$ENV_FILE" <<EOF
DATABASE_URL=${DATA_DIR}/personalities.db
RATE_LIMIT_PUBLISH=${RATE_PUBLISH}
RATE_LIMIT_READ=${RATE_READ}
PUBLIC_BASE_URL=https://${DOMAIN}
EOF
chown "${APP_USER}:${APP_USER}" "$ENV_FILE"
chmod 600 "$ENV_FILE"
success ".env written to ${ENV_FILE}"
# ── Step 5: Install deps & build ──────────────────────────────────────────────
header "Step 5 / 9 — Install & Build"
info "Installing npm dependencies (this may take a minute)…"
sudo -u "$APP_USER" npm --prefix "$APP_DIR" install --quiet
info "Building SvelteKit app…"
sudo -u "$APP_USER" npm --prefix "$APP_DIR" run build
success "Build complete"
# ── Step 6: PM2 ───────────────────────────────────────────────────────────────
header "Step 6 / 9 — PM2 Process Manager"
ECOSYSTEM="${APP_DIR}/ecosystem.config.cjs"
cat > "$ECOSYSTEM" <<EOF
module.exports = {
apps: [{
name: 'etc-prs',
script: '${APP_DIR}/build/index.js',
cwd: '${APP_DIR}',
user: '${APP_USER}',
env: {
NODE_ENV: 'production',
PORT: '3000',
HOST: '127.0.0.1',
},
error_file: '${LOG_DIR}/error.log',
out_file: '${LOG_DIR}/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
restart_delay: 3000,
max_restarts: 10,
}]
};
EOF
chown "${APP_USER}:${APP_USER}" "$ECOSYSTEM"
info "Starting app with PM2…"
sudo -u "$APP_USER" pm2 start "$ECOSYSTEM"
sudo -u "$APP_USER" pm2 save
info "Configuring PM2 to start on boot…"
# Capture the startup command PM2 emits and run it
PM2_STARTUP=$(sudo -u "$APP_USER" pm2 startup systemd -u "$APP_USER" --hp /opt/etc-prs 2>&1 | grep "sudo env")
if [[ -n "$PM2_STARTUP" ]]; then
eval "$PM2_STARTUP"
fi
success "PM2 configured — app is running on 127.0.0.1:3000"
# ── Step 7: Nginx ─────────────────────────────────────────────────────────────
header "Step 7 / 9 — Nginx"
cat > "$NGINX_CONF" <<EOF
server {
listen 80;
server_name ${DOMAIN} www.${DOMAIN};
# Gzip
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
# Proxy to SvelteKit
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
proxy_read_timeout 30s;
client_max_body_size 2M;
}
}
EOF
# Remove default site, enable ours
rm -f /etc/nginx/sites-enabled/default
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/etc-prs
nginx -t || error "Nginx config test failed — check ${NGINX_CONF}"
systemctl reload nginx
success "Nginx configured for ${DOMAIN}"
# ── Step 8: SSL ───────────────────────────────────────────────────────────────
header "Step 8 / 9 — SSL Certificate (Let's Encrypt)"
info "Requesting certificate for ${DOMAIN} and www.${DOMAIN}"
info "DNS must be pointing at this server's IP for this to succeed."
echo ""
if certbot --nginx \
--non-interactive \
--agree-tos \
--email "$SSL_EMAIL" \
--domains "${DOMAIN},www.${DOMAIN}" \
--redirect; then
success "SSL certificate issued and Nginx updated"
info "Verifying auto-renewal…"
certbot renew --dry-run --quiet && success "Auto-renewal check passed"
else
warn "Certbot failed — HTTP is still working but HTTPS is not configured."
warn "Once your DNS is pointing here, run:"
warn " sudo certbot --nginx -d ${DOMAIN} -d www.${DOMAIN} --email ${SSL_EMAIL} --agree-tos"
fi
# ── Step 9: Backup cron + admin user ─────────────────────────────────────────
header "Step 9 / 9 — Backups, Firewall & Admin Account"
# Backup script
cat > "$BACKUP_SCRIPT" <<'EOF'
#!/usr/bin/env bash
DATE=$(date +%Y-%m-%d_%H%M)
BACKUP_DIR=/var/backups/etc-prs
DB_PATH=/var/lib/etc-prs/personalities.db
mkdir -p "$BACKUP_DIR"
sqlite3 "$DB_PATH" ".backup ${BACKUP_DIR}/personalities-${DATE}.db"
gzip "${BACKUP_DIR}/personalities-${DATE}.db"
# Keep only the last 30 days
find "$BACKUP_DIR" -name "personalities-*.db.gz" -mtime +30 -delete
echo "$(date '+%Y-%m-%d %H:%M:%S') Backup OK: personalities-${DATE}.db.gz"
EOF
chmod +x "$BACKUP_SCRIPT"
# Add cron job (2am daily) — idempotent
CRON_LINE="0 2 * * * ${BACKUP_SCRIPT} >> ${LOG_DIR}/backup.log 2>&1"
( crontab -l 2>/dev/null | grep -v "$BACKUP_SCRIPT"; echo "$CRON_LINE" ) | crontab -
success "Backup cron job scheduled (daily at 2am)"
# Firewall
info "Configuring UFW firewall…"
ufw allow OpenSSH > /dev/null
ufw allow 'Nginx Full' > /dev/null
ufw --force enable > /dev/null
success "Firewall enabled (SSH + HTTP + HTTPS)"
# Admin account
info "Creating admin account '${ADMIN_USER}'…"
cd "$APP_DIR"
sudo -u "$APP_USER" node scripts/create-admin.js "$ADMIN_USER" "$ADMIN_PASS" || \
warn "Admin creation failed — run manually: cd ${APP_DIR} && node scripts/create-admin.js <user> <pass>"
success "Admin account '${ADMIN_USER}' created"
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
divider
echo -e "${BOLD}${GREEN} Deployment complete!${RESET}"
divider
echo ""
echo -e " ${BOLD}Site:${RESET} https://${DOMAIN}"
echo -e " ${BOLD}Admin panel:${RESET} https://${DOMAIN}/admin"
echo -e " ${BOLD}App logs:${RESET} sudo -u ${APP_USER} pm2 logs etc-prs"
echo -e " ${BOLD}DB path:${RESET} ${DATA_DIR}/personalities.db"
echo -e " ${BOLD}Backups:${RESET} ${BACKUP_DIR}"
echo ""
echo -e " ${BOLD}Redeploy after a push:${RESET}"
echo -e " ${CYAN} cd ${APP_DIR} && sudo -u ${APP_USER} git pull && \\"
echo -e " sudo -u ${APP_USER} npm install && \\"
echo -e " sudo -u ${APP_USER} npm run build && \\"
echo -e " sudo -u ${APP_USER} pm2 reload etc-prs${RESET}"
echo ""
divider
echo ""
# ── Verify app is responding ──────────────────────────────────────────────────
info "Checking app is responding on port 3000…"
sleep 2
if curl -sf http://127.0.0.1:3000 > /dev/null; then
success "App is up and responding ✓"
else
warn "App did not respond on port 3000. Check logs:"
warn " sudo -u ${APP_USER} pm2 logs etc-prs --lines 30"
fi
echo ""