Add production install script and migrate service

- install.sh: Interactive setup script for Linux VPS/LXC deployment
  - Checks prerequisites (Docker, Docker Compose, OpenSSL)
  - Auto-generates all secrets (Postgres, MinIO, NextAuth, encryption)
  - Creates .env.production with proper Docker service hostnames
  - Builds and starts all services via docker-compose.prod.yml
  - Health check loop with status reporting
  - Idempotent (safe to re-run)

- docker-compose.prod.yml: Add migrate service
  - One-shot container that runs prisma db push before app starts
  - App depends on migrate completing successfully
  - Override DATABASE_URL and MINIO_ENDPOINT for Docker networking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-01 13:40:48 -07:00
parent 8a582bfa7f
commit 1d454d84b2
2 changed files with 364 additions and 0 deletions

View File

@@ -45,6 +45,23 @@ services:
exit 0;
"
migrate:
image: node:20-alpine
depends_on:
db:
condition: service_healthy
working_dir: /app
volumes:
- ./prisma:/app/prisma
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
env_file:
- .env.production
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-drinktracker}"
command: sh -c "npm install prisma @prisma/client --silent && npx prisma db push --skip-generate --accept-data-loss"
restart: "no"
app:
build:
context: .
@@ -57,8 +74,13 @@ services:
condition: service_healthy
minio:
condition: service_healthy
migrate:
condition: service_completed_successfully
env_file:
- .env.production
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER:-drinktracker}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-drinktracker}"
MINIO_ENDPOINT: "minio"
volumes:
pgdata:

342
install.sh Normal file
View File

@@ -0,0 +1,342 @@
#!/usr/bin/env bash
set -euo pipefail
# ─────────────────────────────────────────────────────────────
# DrinkTracker — Production Install Script
# ─────────────────────────────────────────────────────────────
# 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"
# ─── 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..."
# Docker
if command -v docker &>/dev/null; then
DOCKER_VER=$(docker --version 2>/dev/null | head -1)
success "Docker found: $DOCKER_VER"
else
fatal "Docker is not installed. Install it from https://docs.docker.com/engine/install/"
fi
# Docker Compose v2
if docker compose version &>/dev/null; then
COMPOSE_VER=$(docker compose version 2>/dev/null | head -1)
success "Docker Compose found: $COMPOSE_VER"
else
fatal "Docker Compose v2 not found. Install it: https://docs.docker.com/compose/install/"
fi
# OpenSSL
if command -v openssl &>/dev/null; then
success "OpenSSL found"
else
fatal "OpenSSL is not installed. Install it: apt install openssl"
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}OAuth Providers (optional — press Enter to skip):${NC}"
prompt_value GOOGLE_CID "Google Client ID" ""
prompt_value GOOGLE_CS "Google Client Secret" ""
prompt_value GITHUB_CID "GitHub Client ID" ""
prompt_value GITHUB_CS "GitHub Client Secret" ""
# Build DATABASE_URL
DATABASE_URL="postgresql://${PG_USER}:${PG_PASSWORD}@db: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}"
# ─── OAuth Providers ─────────────────────────────────────
GOOGLE_CLIENT_ID="${GOOGLE_CID}"
GOOGLE_CLIENT_SECRET="${GOOGLE_CS}"
GITHUB_CLIENT_ID="${GITHUB_CID}"
GITHUB_CLIENT_SECRET="${GITHUB_CS}"
# ─── MinIO / S3-compatible storage ───────────────────────
MINIO_ENDPOINT="minio"
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}"
ENVEOF
chmod 600 "$ENV_FILE"
success "$ENV_FILE created (permissions: 600)"
fi
# ─── Step 5: Build & Start ──────────────────────────────────
echo ""
info "Building Docker images (this may take a few minutes on first run)..."
echo ""
docker compose -f "$COMPOSE_FILE" build --no-cache
echo ""
info "Starting services..."
echo ""
docker compose -f "$COMPOSE_FILE" 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=$(docker compose -f "$COMPOSE_FILE" ps --format json 2>/dev/null | \
grep -o '"db"[^}]*"healthy"' 2>/dev/null && echo "yes" || echo "no")
# Check app
APP_RUNNING=$(docker compose -f "$COMPOSE_FILE" 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=$(docker compose -f "$COMPOSE_FILE" 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=$(docker compose -f "$COMPOSE_FILE" 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 -f $COMPOSE_FILE logs migrate)"
fi
if [[ "$SERVICES_UP" == "false" ]]; then
echo ""
error "Some services failed to start. Check logs:"
echo " docker compose -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 ""
echo -e " ${BOLD}Useful commands:${NC}"
echo " View logs: docker compose -f $COMPOSE_FILE logs -f"
echo " Stop: docker compose -f $COMPOSE_FILE down"
echo " Restart: docker compose -f $COMPOSE_FILE restart"
echo " Rebuild: docker compose -f $COMPOSE_FILE up -d --build"
echo ""
echo -e " ${BOLD}${YELLOW}Next steps:${NC}"
echo " 1. Open ${APP_URL_CHECK} and create your account"
echo " 2. Add your Claude or OpenAI API key in Settings"
echo " 3. Set up a reverse proxy (nginx/Caddy) for HTTPS"
if [[ -z "${GOOGLE_CID:-}" ]] && [[ -z "${GITHUB_CID:-}" ]]; then
echo " 4. (Optional) Add OAuth providers in $ENV_FILE"
fi
echo ""