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:
@@ -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
342
install.sh
Normal 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 ""
|
||||
Reference in New Issue
Block a user