Both providers were registered but never usable: all four client env vars are empty in production and no Account row has ever been created. Email and password is the only path that has ever worked. This also simplifies the invite gating that follows. With OAuth there were two redemption paths, an invite token that had to survive the provider round trip in a SameSite=Lax cookie, and an OAuthAccountNotLinked dead end for anyone who signed up with a password and later clicked a provider button. Now there is one path. Account, Session and VerificationToken stay in the schema. They are Auth.js's tables and dropping them would be a destructive migration for no benefit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
411 lines
13 KiB
Bash
411 lines
13 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# DrinkTracker — Production Install Script (Docker)
|
|
#
|
|
# For a fresh Docker-based install on a normal host or VPS.
|
|
#
|
|
# NOTE: the production LXC (192.168.2.169) no longer uses this — it runs the app
|
|
# natively under systemd because Docker can't work in an unprivileged LXC. To
|
|
# deploy there, use ./deploy/deploy.sh. See deploy/README.md.
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
NC='\033[0m' # No Color
|
|
|
|
COMPOSE_FILE="docker-compose.prod.yml"
|
|
ENV_FILE=".env.production"
|
|
|
|
# Helper: full docker compose command with env file
|
|
dc() {
|
|
${DOCKER_SUDO:-} docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
|
}
|
|
|
|
# ─── Helpers ─────────────────────────────────────────────────
|
|
|
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
|
success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|
error() { echo -e "${RED}[ERR]${NC} $*"; }
|
|
fatal() { error "$*"; exit 1; }
|
|
|
|
prompt_value() {
|
|
local var_name="$1"
|
|
local prompt_text="$2"
|
|
local default_value="${3:-}"
|
|
local value
|
|
|
|
if [[ -n "$default_value" ]]; then
|
|
echo -en "${CYAN}$prompt_text${NC} [${default_value}]: "
|
|
read -r value
|
|
value="${value:-$default_value}"
|
|
else
|
|
echo -en "${CYAN}$prompt_text${NC}: "
|
|
read -r value
|
|
fi
|
|
|
|
eval "$var_name=\"$value\""
|
|
}
|
|
|
|
prompt_secret() {
|
|
local var_name="$1"
|
|
local prompt_text="$2"
|
|
local auto_value="$3"
|
|
|
|
echo -en "${CYAN}$prompt_text${NC} [auto-generate]: "
|
|
read -r value
|
|
if [[ -z "$value" ]]; then
|
|
value="$auto_value"
|
|
echo -e " Generated: ${BOLD}${value:0:12}...${NC}"
|
|
fi
|
|
|
|
eval "$var_name=\"$value\""
|
|
}
|
|
|
|
check_port() {
|
|
local port="$1"
|
|
if ss -tlnp 2>/dev/null | grep -q ":${port} " || \
|
|
netstat -tlnp 2>/dev/null | grep -q ":${port} "; then
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ─── Banner ──────────────────────────────────────────────────
|
|
|
|
echo ""
|
|
echo -e "${BOLD}${CYAN}"
|
|
echo " ╔══════════════════════════════════════╗"
|
|
echo " ║ DrinkTracker Install ║"
|
|
echo " ║ Production Deployment via Docker ║"
|
|
echo " ╚══════════════════════════════════════╝"
|
|
echo -e "${NC}"
|
|
echo ""
|
|
|
|
# ─── Step 1: Prerequisites ──────────────────────────────────
|
|
|
|
info "Checking prerequisites..."
|
|
|
|
# Detect package manager
|
|
install_pkg() {
|
|
if command -v apt-get &>/dev/null; then
|
|
sudo apt-get update -qq && sudo apt-get install -y -qq "$@"
|
|
elif command -v dnf &>/dev/null; then
|
|
sudo dnf install -y -q "$@"
|
|
elif command -v yum &>/dev/null; then
|
|
sudo yum install -y -q "$@"
|
|
elif command -v apk &>/dev/null; then
|
|
sudo apk add --quiet "$@"
|
|
else
|
|
fatal "Could not detect package manager. Install manually: $*"
|
|
fi
|
|
}
|
|
|
|
# Docker
|
|
if command -v docker &>/dev/null; then
|
|
DOCKER_VER=$(docker --version 2>/dev/null | head -1)
|
|
success "Docker found: $DOCKER_VER"
|
|
else
|
|
warn "Docker is not installed."
|
|
echo -en "${CYAN}Install Docker now? [Y/n]:${NC} "
|
|
read -r install_docker
|
|
if [[ ! "$install_docker" =~ ^[Nn] ]]; then
|
|
info "Installing Docker via official install script..."
|
|
curl -fsSL https://get.docker.com | sudo sh
|
|
sudo systemctl enable --now docker 2>/dev/null || true
|
|
# Add current user to docker group so sudo isn't needed
|
|
if ! groups | grep -q docker; then
|
|
sudo usermod -aG docker "$USER"
|
|
warn "Added $USER to docker group. You may need to log out and back in."
|
|
warn "For now, the script will use sudo for docker commands."
|
|
# Use sudo for docker for the rest of this script
|
|
DOCKER_SUDO="sudo"
|
|
fi
|
|
success "Docker installed: $(docker --version 2>/dev/null || sudo docker --version 2>/dev/null)"
|
|
else
|
|
fatal "Docker is required. Install it from https://docs.docker.com/engine/install/"
|
|
fi
|
|
fi
|
|
|
|
DOCKER_SUDO="${DOCKER_SUDO:-}"
|
|
|
|
# Docker Compose v2
|
|
if ${DOCKER_SUDO} docker compose version &>/dev/null; then
|
|
COMPOSE_VER=$(${DOCKER_SUDO} docker compose version 2>/dev/null | head -1)
|
|
success "Docker Compose found: $COMPOSE_VER"
|
|
else
|
|
warn "Docker Compose v2 not found."
|
|
echo -en "${CYAN}Install Docker Compose plugin now? [Y/n]:${NC} "
|
|
read -r install_compose
|
|
if [[ ! "$install_compose" =~ ^[Nn] ]]; then
|
|
info "Installing Docker Compose plugin..."
|
|
if command -v apt-get &>/dev/null; then
|
|
sudo apt-get update -qq && sudo apt-get install -y -qq docker-compose-plugin
|
|
else
|
|
# Manual install for non-apt systems
|
|
COMPOSE_VERSION=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep '"tag_name"' | head -1 | cut -d'"' -f4)
|
|
sudo mkdir -p /usr/local/lib/docker/cli-plugins
|
|
sudo curl -SL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-$(uname -m)" \
|
|
-o /usr/local/lib/docker/cli-plugins/docker-compose
|
|
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
|
|
fi
|
|
success "Docker Compose installed: $(${DOCKER_SUDO} docker compose version 2>/dev/null)"
|
|
else
|
|
fatal "Docker Compose v2 is required. Install it: https://docs.docker.com/compose/install/"
|
|
fi
|
|
fi
|
|
|
|
# OpenSSL
|
|
if command -v openssl &>/dev/null; then
|
|
success "OpenSSL found"
|
|
else
|
|
warn "OpenSSL not found. Installing..."
|
|
install_pkg openssl
|
|
success "OpenSSL installed"
|
|
fi
|
|
|
|
# curl (needed for health checks)
|
|
if ! command -v curl &>/dev/null; then
|
|
warn "curl not found. Installing..."
|
|
install_pkg curl
|
|
success "curl installed"
|
|
fi
|
|
|
|
# Compose file exists
|
|
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
|
fatal "$COMPOSE_FILE not found. Run this script from the drinktracker project root."
|
|
fi
|
|
|
|
# Dockerfile exists
|
|
if [[ ! -f "Dockerfile" ]]; then
|
|
fatal "Dockerfile not found. Run this script from the drinktracker project root."
|
|
fi
|
|
|
|
# Check ports
|
|
PORTS_OK=true
|
|
for port in 3000; do
|
|
if ! check_port "$port"; then
|
|
warn "Port $port is already in use"
|
|
PORTS_OK=false
|
|
fi
|
|
done
|
|
|
|
if [[ "$PORTS_OK" == "false" ]]; then
|
|
echo ""
|
|
warn "Some ports are in use. The install may fail if those services conflict."
|
|
echo -en "${CYAN}Continue anyway? [y/N]:${NC} "
|
|
read -r confirm
|
|
[[ "$confirm" =~ ^[Yy] ]] || exit 1
|
|
fi
|
|
|
|
echo ""
|
|
success "All prerequisites met!"
|
|
echo ""
|
|
|
|
# ─── Step 2: Check for existing config ──────────────────────
|
|
|
|
if [[ -f "$ENV_FILE" ]]; then
|
|
echo ""
|
|
warn "Existing $ENV_FILE found."
|
|
echo -en "${CYAN}Overwrite with new config? [y/N]:${NC} "
|
|
read -r overwrite
|
|
if [[ ! "$overwrite" =~ ^[Yy] ]]; then
|
|
info "Keeping existing $ENV_FILE. Skipping to build step..."
|
|
SKIP_CONFIG=true
|
|
else
|
|
SKIP_CONFIG=false
|
|
fi
|
|
else
|
|
SKIP_CONFIG=false
|
|
fi
|
|
|
|
# ─── Step 3: Interactive Configuration ──────────────────────
|
|
|
|
if [[ "$SKIP_CONFIG" == "false" ]]; then
|
|
echo -e "${BOLD}Configure your deployment:${NC}"
|
|
echo ""
|
|
|
|
# App URL
|
|
prompt_value APP_URL "App URL (your domain or IP)" "http://localhost:3000"
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Database:${NC}"
|
|
prompt_value PG_USER "PostgreSQL username" "drinktracker"
|
|
prompt_secret PG_PASSWORD "PostgreSQL password" "$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)"
|
|
prompt_value PG_DB "PostgreSQL database" "drinktracker"
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Object Storage (MinIO):${NC}"
|
|
prompt_secret MINIO_AK "MinIO access key" "$(openssl rand -base64 16 | tr -d '/+=' | head -c 20)"
|
|
prompt_secret MINIO_SK "MinIO secret key" "$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Security (press Enter to auto-generate):${NC}"
|
|
prompt_secret AUTH_SECRET "NextAuth secret" "$(openssl rand -base64 32)"
|
|
prompt_secret ENC_KEY "Encryption key" "$(openssl rand -hex 32)"
|
|
|
|
echo ""
|
|
echo -e "${BOLD}AI Gateway:${NC}"
|
|
prompt_value SWITCHBOARD_URL "Switchboard gateway URL" "http://192.168.2.11:8787/v1"
|
|
|
|
|
|
# Build DATABASE_URL
|
|
DATABASE_URL="postgresql://${PG_USER}:${PG_PASSWORD}@localhost:5432/${PG_DB}"
|
|
|
|
# ─── Step 4: Write .env.production ───────────────────────
|
|
|
|
echo ""
|
|
info "Writing $ENV_FILE..."
|
|
|
|
cat > "$ENV_FILE" <<ENVEOF
|
|
# ─── Database ────────────────────────────────────────────
|
|
DATABASE_URL="${DATABASE_URL}"
|
|
POSTGRES_USER="${PG_USER}"
|
|
POSTGRES_PASSWORD="${PG_PASSWORD}"
|
|
POSTGRES_DB="${PG_DB}"
|
|
|
|
# ─── NextAuth ────────────────────────────────────────────
|
|
NEXTAUTH_URL="${APP_URL}"
|
|
NEXTAUTH_SECRET="${AUTH_SECRET}"
|
|
AUTH_TRUST_HOST="true"
|
|
|
|
|
|
# ─── MinIO / S3-compatible storage ───────────────────────
|
|
MINIO_ENDPOINT="localhost"
|
|
MINIO_PORT="9000"
|
|
MINIO_ACCESS_KEY="${MINIO_AK}"
|
|
MINIO_SECRET_KEY="${MINIO_SK}"
|
|
MINIO_BUCKET="drink-images"
|
|
MINIO_USE_SSL="false"
|
|
|
|
# ─── Encryption (for API key storage) ────────────────────
|
|
ENCRYPTION_KEY="${ENC_KEY}"
|
|
|
|
# ─── AI Gateway (Switchboard) ────────────────────────────
|
|
SWITCHBOARD_BASE_URL="${SWITCHBOARD_URL}"
|
|
ENVEOF
|
|
|
|
chmod 600 "$ENV_FILE"
|
|
success "$ENV_FILE created (permissions: 600)"
|
|
fi
|
|
|
|
# ─── Step 5: Pull & Start ────────────────────────────────────
|
|
|
|
echo ""
|
|
info "Pulling Docker images..."
|
|
echo ""
|
|
|
|
dc pull
|
|
|
|
echo ""
|
|
info "Starting services..."
|
|
echo ""
|
|
|
|
dc up -d
|
|
|
|
# ─── Step 6: Health Check ───────────────────────────────────
|
|
|
|
echo ""
|
|
info "Waiting for services to become healthy..."
|
|
|
|
MAX_WAIT=90
|
|
ELAPSED=0
|
|
INTERVAL=5
|
|
|
|
while [[ $ELAPSED -lt $MAX_WAIT ]]; do
|
|
# Check database
|
|
DB_HEALTHY=$(dc ps --format json 2>/dev/null | \
|
|
grep -o '"db"[^}]*"healthy"' 2>/dev/null && echo "yes" || echo "no")
|
|
|
|
# Check app
|
|
APP_RUNNING=$(dc ps --status running --format json 2>/dev/null | \
|
|
grep -o '"app"' 2>/dev/null && echo "yes" || echo "no")
|
|
|
|
if [[ "$DB_HEALTHY" == *"yes"* ]] && [[ "$APP_RUNNING" == *"yes"* ]]; then
|
|
break
|
|
fi
|
|
|
|
echo -ne "\r Waiting... ${ELAPSED}s / ${MAX_WAIT}s"
|
|
sleep $INTERVAL
|
|
ELAPSED=$((ELAPSED + INTERVAL))
|
|
done
|
|
|
|
echo ""
|
|
|
|
# Verify services are running
|
|
SERVICES_UP=true
|
|
|
|
for service in db minio app; do
|
|
STATUS=$(dc ps "$service" --format "{{.Status}}" 2>/dev/null || echo "not found")
|
|
if echo "$STATUS" | grep -qi "up\|running\|healthy"; then
|
|
success "$service: $STATUS"
|
|
else
|
|
error "$service: $STATUS"
|
|
SERVICES_UP=false
|
|
fi
|
|
done
|
|
|
|
# Check migrate completed
|
|
MIGRATE_STATUS=$(dc ps migrate --format "{{.Status}}" 2>/dev/null || echo "not found")
|
|
if echo "$MIGRATE_STATUS" | grep -qi "exited (0)\|completed"; then
|
|
success "migrate: completed successfully"
|
|
else
|
|
warn "migrate: $MIGRATE_STATUS (check logs: docker compose --env-file $ENV_FILE -f $COMPOSE_FILE logs migrate)"
|
|
fi
|
|
|
|
if [[ "$SERVICES_UP" == "false" ]]; then
|
|
echo ""
|
|
error "Some services failed to start. Check logs:"
|
|
echo " docker compose --env-file $ENV_FILE -f $COMPOSE_FILE logs"
|
|
exit 1
|
|
fi
|
|
|
|
# Quick HTTP check
|
|
echo ""
|
|
info "Checking app connectivity..."
|
|
sleep 3
|
|
|
|
APP_URL_CHECK="${APP_URL:-http://localhost:3000}"
|
|
if command -v curl &>/dev/null; then
|
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL_CHECK" --max-time 10 2>/dev/null || echo "000")
|
|
if [[ "$HTTP_CODE" =~ ^(200|302|307)$ ]]; then
|
|
success "App responding (HTTP $HTTP_CODE)"
|
|
else
|
|
warn "App returned HTTP $HTTP_CODE — it may still be starting up"
|
|
fi
|
|
else
|
|
info "curl not available, skipping HTTP check"
|
|
fi
|
|
|
|
# ─── Step 7: Summary ────────────────────────────────────────
|
|
|
|
echo ""
|
|
echo -e "${BOLD}${GREEN}"
|
|
echo " ╔══════════════════════════════════════╗"
|
|
echo " ║ Installation Complete! ║"
|
|
echo " ╚══════════════════════════════════════╝"
|
|
echo -e "${NC}"
|
|
echo ""
|
|
echo -e " ${BOLD}App URL:${NC} ${APP_URL_CHECK}"
|
|
echo -e " ${BOLD}Config file:${NC} $(pwd)/$ENV_FILE"
|
|
echo ""
|
|
DC_CMD="docker compose --env-file $ENV_FILE -f $COMPOSE_FILE"
|
|
echo -e " ${BOLD}Useful commands:${NC}"
|
|
echo " View logs: $DC_CMD logs -f"
|
|
echo " Stop: $DC_CMD down"
|
|
echo " Restart: $DC_CMD restart"
|
|
echo " Update: $DC_CMD pull && $DC_CMD up -d"
|
|
echo ""
|
|
echo -e " ${BOLD}${YELLOW}Next steps:${NC}"
|
|
echo " 1. Open ${APP_URL_CHECK} and create your account"
|
|
echo " 2. Add your Switchboard API key in Settings"
|
|
echo " 3. Set up a reverse proxy (nginx/Caddy) for HTTPS"
|
|
echo ""
|