390 lines
16 KiB
Bash
390 lines
16 KiB
Bash
#!/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 (2–32 chars): " ADMIN_USER
|
||
[[ ${#ADMIN_USER} -ge 2 && ${#ADMIN_USER} -le 32 ]] && break
|
||
warn "Username must be 2–32 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 ""
|