diff --git a/apps/landing/.env.example b/apps/landing/.env.example new file mode 100644 index 0000000..5a126f8 --- /dev/null +++ b/apps/landing/.env.example @@ -0,0 +1,6 @@ +# Landing App Environment Variables + +# Waitlist logs path (absolute path required) +# Development: use local directory +# Production: use Docker volume path +WAITLIST_LOGS_PATH=/absolute/path/to/waitlist-logs diff --git a/apps/landing/.gitignore b/apps/landing/.gitignore index 5ef6a52..9ff866b 100644 --- a/apps/landing/.gitignore +++ b/apps/landing/.gitignore @@ -32,6 +32,10 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example + +# waitlist logs +/waitlist-logs/ # vercel .vercel diff --git a/apps/landing/package.json b/apps/landing/package.json index 6b93aea..f351c93 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -5,8 +5,8 @@ "scripts": { "dev": "next dev -p 3010", "build": "next build", - "start": "next start", - "deploy": "cp -r out/* /var/www/banatie.app/", + "postbuild": "cp -r .next/static .next/standalone/apps/landing/.next/ && cp -r public .next/standalone/apps/landing/", + "start": "node .next/standalone/apps/landing/server.js", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/landing/src/app/homepage/_components/HeroSection.tsx b/apps/landing/src/app/homepage/_components/HeroSection.tsx index 7aa92bc..4618536 100644 --- a/apps/landing/src/app/homepage/_components/HeroSection.tsx +++ b/apps/landing/src/app/homepage/_components/HeroSection.tsx @@ -1,9 +1,10 @@ 'use client'; import { useState } from 'react'; -import { Zap, Globe, FlaskConical, AtSign, Link } from 'lucide-react'; +import { Zap, Globe, FlaskConical, AtSign, Link, Check } from 'lucide-react'; import GlowEffect from './GlowEffect'; import WaitlistPopup from './WaitlistPopup'; +import { submitEmail, submitWaitlistData } from '@/lib/actions/waitlistActions'; export const styles = ` .gradient-text { @@ -34,6 +35,44 @@ export const styles = ` 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } + + .hero-btn { + transition: all 0.2s ease; + } + + .hero-btn.enabled { + cursor: pointer; + } + + .hero-btn.disabled { + cursor: not-allowed; + } + + .hero-btn.disabled:hover { + background: rgba(100, 100, 120, 0.4) !important; + } + + .success-checkmark-ring { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(16, 185, 129, 0.1) 100%); + border: 1px solid rgba(34, 197, 94, 0.3); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .success-checkmark-inner { + width: 22px; + height: 22px; + border-radius: 50%; + background: linear-gradient(135deg, #22c55e 0%, #10b981 100%); + display: flex; + align-items: center; + justify-content: center; + } `; const badges = [ @@ -44,21 +83,42 @@ const badges = [ { icon: Link, text: 'Prompt URLs', variant: 'cyan' }, ]; +const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + export function HeroSection() { const [email, setEmail] = useState(''); + const [isInvalid, setIsInvalid] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); const [showPopup, setShowPopup] = useState(false); - const handleSubmit = (e: React.FormEvent) => { + const isEnabled = email.length > 0 && isValidEmail(email); + + const handleEmailChange = (e: React.ChangeEvent) => { + setEmail(e.target.value); + if (isInvalid) setIsInvalid(false); + }; + + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - console.log('Email submitted:', email); + if (!isValidEmail(email)) { + setIsInvalid(true); + return; + } + setIsInvalid(false); + + // Fire and forget - don't block popup on logging failure + submitEmail(email).catch(console.error); setShowPopup(true); }; - const handleWaitlistSubmit = async (data: { selected: string[]; other: string }) => { - await fetch('/api/brevo', { - method: 'POST', - body: JSON.stringify({ email, useCases: data.selected, other: data.other }), - }); + const handlePopupClose = () => { + setShowPopup(false); + setIsSubmitted(true); + }; + + const handleWaitlistSubmit = (data: { selected: string[]; other: string }) => { + // Fire and forget - don't block on logging failure + submitWaitlistData(email, data).catch(console.error); }; return ( @@ -82,25 +142,44 @@ export function HeroSection() {

-
- setEmail(e.target.value)} - placeholder="your@email.com" - className="flex-1 px-4 py-3 bg-transparent border-none rounded-md text-white outline-none placeholder:text-white/40 focus:bg-white/[0.03]" - /> - -
+
+
+ +
+
+ Done! You're in the list + + ) : ( +
+ + +
+ )}

Free early access. No credit card required.

@@ -125,7 +204,7 @@ export function HeroSection() { setShowPopup(false)} + onClose={handlePopupClose} onSubmit={handleWaitlistSubmit} /> diff --git a/apps/landing/src/lib/actions/waitlistActions.ts b/apps/landing/src/lib/actions/waitlistActions.ts new file mode 100644 index 0000000..0444fb6 --- /dev/null +++ b/apps/landing/src/lib/actions/waitlistActions.ts @@ -0,0 +1,50 @@ +'use server'; + +import { storeMail, updateMail } from '@/lib/logger/waitlistLogger'; + +const submissionTimestamps = new Map(); +const RATE_LIMIT_WINDOW = 60000; +const MAX_SUBMISSIONS = 3; + +function isRateLimited(identifier: string): boolean { + const now = Date.now(); + const timestamps = submissionTimestamps.get(identifier) || []; + const recentTimestamps = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW); + + if (recentTimestamps.length >= MAX_SUBMISSIONS) { + return true; + } + + submissionTimestamps.set(identifier, [...recentTimestamps, now]); + return false; +} + +export async function submitEmail(email: string): Promise<{ success: boolean; error?: string }> { + try { + if (isRateLimited(email)) { + console.warn('[WAITLIST] Rate limited:', email); + return { success: false, error: 'Too many requests' }; + } + + await storeMail(email); + console.log('[WAITLIST] Email stored:', email); + return { success: true }; + } catch (error) { + console.error('[WAITLIST] Failed to store email:', email, error); + return { success: false, error: 'Failed to save' }; + } +} + +export async function submitWaitlistData( + email: string, + data: { selected: string[]; other: string } +): Promise<{ success: boolean; error?: string }> { + try { + await updateMail(email, data); + console.log('[WAITLIST] Data updated:', email, data.selected); + return { success: true }; + } catch (error) { + console.error('[WAITLIST] Failed to update data:', email, error); + return { success: false, error: 'Failed to save' }; + } +} diff --git a/apps/landing/src/lib/logger/waitlistLogger.ts b/apps/landing/src/lib/logger/waitlistLogger.ts new file mode 100644 index 0000000..0ec2a0b --- /dev/null +++ b/apps/landing/src/lib/logger/waitlistLogger.ts @@ -0,0 +1,120 @@ +import fs from 'fs/promises'; +import path from 'path'; + +const LOGS_PATH = process.env.WAITLIST_LOGS_PATH; + +let isInitialized = false; + +async function ensureInitialized(): Promise { + if (isInitialized) return; + + if (!LOGS_PATH) { + throw new Error('WAITLIST_LOGS_PATH environment variable is not set'); + } + + try { + await fs.access(LOGS_PATH); + } catch { + throw new Error(`Logs directory does not exist: ${LOGS_PATH}`); + } + + const testFile = path.join(LOGS_PATH, '.write-test'); + try { + await fs.writeFile(testFile, 'test'); + await fs.unlink(testFile); + } catch { + throw new Error(`No write access to logs directory: ${LOGS_PATH}`); + } + + isInitialized = true; +} + +function sanitizeEmail(email: string): string { + return email + .toLowerCase() + .replace(/[^a-z0-9@._-]/g, '_') + .replace(/@/g, '-at-') + .substring(0, 50); +} + +function getMonth(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; +} + +async function getNextIndex(): Promise { + await ensureInitialized(); + + const files = await fs.readdir(LOGS_PATH!); + const mailFiles = files.filter((f) => f.startsWith('mail-')); + + let maxIndex = 0; + for (const file of mailFiles) { + const match = file.match(/^mail-(\d+)-/); + if (match) { + maxIndex = Math.max(maxIndex, parseInt(match[1], 10)); + } + } + + return String(maxIndex + 1).padStart(3, '0'); +} + +async function findFileByEmail(email: string): Promise { + await ensureInitialized(); + + const sanitized = sanitizeEmail(email); + const files = await fs.readdir(LOGS_PATH!); + + const match = files.find((f) => f.endsWith(`${sanitized}.md`)); + return match ? path.join(LOGS_PATH!, match) : null; +} + +export async function storeMail(email: string): Promise { + await ensureInitialized(); + + const index = await getNextIndex(); + const month = getMonth(); + const sanitized = sanitizeEmail(email); + const filename = `mail-${index}-${month}-${sanitized}.md`; + const filepath = path.join(LOGS_PATH!, filename); + + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); + + const content = `${email} +${timestamp} +`; + + await fs.writeFile(filepath, content, 'utf-8'); +} + +export async function updateMail(email: string, data: { selected: string[]; other: string }): Promise { + await ensureInitialized(); + + const filepath = await findFileByEmail(email); + + if (!filepath) { + await storeMail(email); + const newFilepath = await findFileByEmail(email); + if (!newFilepath) throw new Error('Failed to create mail file'); + await appendUseCases(newFilepath, data); + return; + } + + await appendUseCases(filepath, data); +} + +async function appendUseCases(filepath: string, data: { selected: string[]; other: string }): Promise { + const existing = await fs.readFile(filepath, 'utf-8'); + + let useCasesSection = '\n### Use Cases\n'; + + for (const useCase of data.selected) { + useCasesSection += `- ${useCase}\n`; + } + + if (data.other) { + useCasesSection += `- Other: "${data.other}"\n`; + } + + await fs.writeFile(filepath, existing + useCasesSection, 'utf-8'); +} diff --git a/infrastructure-v1.md b/infrastructure-v1.md new file mode 100644 index 0000000..4677d08 --- /dev/null +++ b/infrastructure-v1.md @@ -0,0 +1,947 @@ +# Banatie Service Infrastructure Documentation v1.0 + +## Overview + +This document defines the complete containerization and deployment architecture for the Banatie AI image generation service on the usul.su VPS infrastructure. + +**Target VPS:** 62.146.239.118 (Contabo Singapore) +**Environment:** Ubuntu 24.04.2 LTS with Docker User Namespace Remapping + +## Architecture Summary + +### Core Principles + +- **Complete Service Isolation**: Banatie ecosystem is fully isolated from core VPS services (NextCloud, Gitea) +- **Dedicated Database**: Separate PostgreSQL container exclusively for Banatie +- **S3-Compatible Storage**: MinIO SNMD (Single Node Multi Drive) for full S3 compatibility +- **Container-First Approach**: All components run as Docker containers +- **Network Segregation**: Internal communication via dedicated Docker networks +- **No Port Exposure**: All external access via Caddy reverse proxy only + +### Service Components + +``` +Banatie Ecosystem (Isolated from /opt/services/) +├── Banatie API (Express.js/TypeScript) → api.banatie.app +├── Banatie Landing (Next.js standalone) → banatie.app +├── PostgreSQL 15 (Dedicated instance) +├── MinIO SNMD (S3-compatible storage) +│ ├── Console → storage.banatie.app +│ └── S3 API → cdn.banatie.app +└── Storage Init (one-time bucket setup) +``` + +### Domain Architecture + +| Domain | Service | Container | Port | +|--------|---------|-----------|------| +| `banatie.app` | Landing Page | banatie-landing | 3000 | +| `api.banatie.app` | REST API | banatie-api | 3000 | +| `storage.banatie.app` | MinIO Console | banatie-minio | 9001 | +| `cdn.banatie.app` | MinIO S3 (images) | banatie-minio | 9000 | + +## Network Architecture + +### Production Networks + +```yaml +networks: + banatie-internal: + driver: bridge + internal: true # 🔒 CRITICAL: No external internet access + + proxy-network: + external: true # Existing Caddy reverse proxy network +``` + +### Network Access Matrix + +| Service | banatie-internal | proxy-network | External Access | +|---------|-----------------|---------------|-----------------| +| banatie-api | ✅ Internal | ✅ HTTP only | ❌ Direct | +| banatie-landing | ✅ Internal | ✅ HTTP only | ❌ Direct | +| banatie-postgres | ✅ Internal | ❌ None | ❌ None | +| banatie-minio | ✅ Internal | ✅ Console+S3 | ❌ Direct | +| Caddy (external) | ❌ None | ✅ Routing | ✅ Internet | + +### VPS Integration Points + +**Isolation from Core Services:** +- **NO** shared resources with `/opt/services/` infrastructure +- **NO** access to NextCloud, Gitea, or core PostgreSQL +- **ONLY** connection point: Caddy reverse proxy (proxy-network) + +**Shared Infrastructure:** +- Caddy reverse proxy for SSL termination and routing +- Host filesystem for persistent data storage +- UFW firewall rules and Docker User Namespace Remapping + +## Directory Structure + +### Production Environment (VPS) + +``` +/opt/banatie/ # Isolated Banatie deployment +├── docker-compose.yml # Production configuration +├── .env # Public environment variables +├── secrets.env # Sensitive secrets (chmod 600) +├── scripts/ +│ └── init-db.sql # PostgreSQL 15 permission fix (minimal) +├── data/ # Persistent data (Docker-managed!) +│ ├── postgres/ # PostgreSQL data +│ ├── minio/ # MinIO SNMD storage +│ │ ├── drive1/ +│ │ ├── drive2/ +│ │ ├── drive3/ +│ │ └── drive4/ +│ └── waitlist-logs/ # Landing waitlist emails +└── logs/ + ├── api/ # API service logs + └── landing/ # Landing app logs + +# Source code (separate from production) +~/workspace/projects/banatie-service/ +├── apps/ +│ ├── api-service/ # Express.js API source +│ │ └── Dockerfile # Production build +│ └── landing/ # Next.js landing source +│ └── Dockerfile # Production build +├── packages/ +│ └── database/ # Drizzle ORM schema (source of truth) +│ ├── src/schema/ # Actual database schema +│ └── migrations/ # Drizzle migrations +└── prod-env/ # Local testing (NOT for VPS) +``` + +### Source Code Repository + +Source code is managed in `~/workspace/projects/banatie-service/` and cloned from Gitea: +```bash +git clone ssh://git@git.usul.su:2222/usulpro/banatie-service.git +``` + +Production configuration lives in `/opt/banatie/` (separate from source). + +## Docker User Namespace Remapping + +**Status:** ✅ ENABLED on VPS for enhanced container security + +**Configuration:** `/etc/docker/daemon.json` +```json +{ + "userns-remap": "default" +} +``` + +**What This Means:** +- Container UIDs are mapped to high-numbered host UIDs (base offset: 165536) +- PostgreSQL UID 70 inside container → UID 165606 on host +- MinIO UID 1000 inside container → UID 166536 on host +- Enhanced security: containers cannot access host files with original UIDs + +### Critical Rules + +- ❌ **DO NOT** manually create directories in `/opt/banatie/data/` +- ❌ **DO NOT** manually chown data directories +- ✅ **LET DOCKER** handle directory creation and permissions +- ✅ Use `docker exec` for container file operations + +### Troubleshooting Permission Issues + +If a service fails with permission errors after VPS reboot: +```bash +# Check container's internal UID +docker exec banatie-postgres id +# Expected output: uid=70(postgres) + +# Calculate host UID: 70 + 165536 = 165606 +# Fix permissions if needed +sudo chown -R 165606:165606 /opt/banatie/data/postgres +``` + +## Container Specifications + +### 1. Banatie API Container + +**Base Image**: `node:20-alpine` +**Build Strategy**: Multi-stage for optimization +**Source**: `apps/api-service/Dockerfile` + +**Features:** +- Non-root user (apiuser:nodejs, UID 1001) +- Health check endpoint at `/health` +- Structured logging to `/app/apps/api-service/logs` +- Database connection via Drizzle ORM + +### 2. Banatie Landing Container + +**Base Image**: `node:20-alpine` +**Build Strategy**: Multi-stage Next.js standalone +**Source**: `apps/landing/Dockerfile` + +**Features:** +- Non-root user (nextjs:nodejs, UID 1001) +- Health check at root path +- Waitlist email logging to `/app/waitlist-logs` + +### 3. PostgreSQL Container + +**Image**: `postgres:15-alpine` +**Purpose**: Dedicated database for Banatie services +**Internal UID**: 70 (postgres user) + +**Database Schema** (managed by Drizzle ORM): +- `organizations` - Multi-tenant organization data +- `projects` - Projects within organizations +- `api_keys` - API key authentication +- `flows` - Generation workflow definitions +- `images` - Image metadata and references +- `generations` - Generation history +- `prompt_url_cache` - URL caching for prompts +- `live_scopes` - Live scope definitions + +**Note**: Schema is defined in `packages/database/src/schema/` and managed via Drizzle. The `scripts/init-db.sql` contains only PostgreSQL 15 permission fixes. + +### 4. MinIO Container + +**Image**: `quay.io/minio/minio:latest` +**Purpose**: S3-compatible object storage +**Mode**: SNMD (Single Node Multi Drive) for full S3 compatibility and erasure coding + +**Storage Configuration:** +```yaml +volumes: + - /opt/banatie/data/minio/drive1:/data1 + - /opt/banatie/data/minio/drive2:/data2 + - /opt/banatie/data/minio/drive3:/data3 + - /opt/banatie/data/minio/drive4:/data4 +command: server /data{1...4} --console-address ":9001" +``` + +**Why SNMD:** +- Full S3 API compatibility +- Erasure coding for data durability +- Required for Banatie startup project requirements + +### 5. Storage Init Container + +**Image**: `minio/mc:latest` +**Purpose**: One-time MinIO initialization +**Restart Policy**: `no` (runs once) + +**Initialization Tasks:** +1. Create main bucket (`banatie`) +2. Create service account for application access +3. Attach readwrite policy to service account +4. Configure lifecycle policy for temp file cleanup (7 days) + +## Production Docker Compose + +**File**: `/opt/banatie/docker-compose.yml` + +```yaml +# Banatie Production - VPS Isolated Deployment +# Last Updated: December 2025 + +services: + # =========================================== + # API Service (Express.js) + # =========================================== + banatie-api: + build: + context: /home/usul/workspace/projects/banatie-service + dockerfile: apps/api-service/Dockerfile + target: production + container_name: banatie-api + restart: unless-stopped + networks: + - banatie-internal + - proxy-network + depends_on: + banatie-postgres: + condition: service_healthy + banatie-minio: + condition: service_healthy + env_file: + - /opt/banatie/.env + - /opt/banatie/secrets.env + environment: + - IS_DOCKER=true + - NODE_ENV=production + volumes: + - /opt/banatie/logs/api:/app/apps/api-service/logs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # =========================================== + # Landing Page (Next.js standalone) + # =========================================== + banatie-landing: + build: + context: /home/usul/workspace/projects/banatie-service + dockerfile: apps/landing/Dockerfile + container_name: banatie-landing + restart: unless-stopped + networks: + - banatie-internal + - proxy-network + depends_on: + - banatie-postgres + env_file: + - /opt/banatie/.env + - /opt/banatie/secrets.env + environment: + - IS_DOCKER=true + - NODE_ENV=production + - WAITLIST_LOGS_PATH=/app/waitlist-logs + volumes: + - /opt/banatie/data/waitlist-logs:/app/waitlist-logs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + # =========================================== + # PostgreSQL (Dedicated - ISOLATED) + # =========================================== + banatie-postgres: + image: postgres:15-alpine + container_name: banatie-postgres + restart: unless-stopped + networks: + - banatie-internal + # NO proxy-network - complete isolation from external access + volumes: + - /opt/banatie/data/postgres:/var/lib/postgresql/data + - /opt/banatie/scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql:ro + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # =========================================== + # MinIO Object Storage (SNMD - 4 drives) + # =========================================== + banatie-minio: + image: quay.io/minio/minio:latest + container_name: banatie-minio + restart: unless-stopped + networks: + - banatie-internal + - proxy-network + volumes: + - /opt/banatie/data/minio/drive1:/data1 + - /opt/banatie/data/minio/drive2:/data2 + - /opt/banatie/data/minio/drive3:/data3 + - /opt/banatie/data/minio/drive4:/data4 + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + MINIO_BROWSER_REDIRECT_URL: https://storage.banatie.app + MINIO_SERVER_URL: https://cdn.banatie.app + command: server /data{1...4} --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # =========================================== + # MinIO Initialization (one-time) + # =========================================== + banatie-storage-init: + image: minio/mc:latest + container_name: banatie-storage-init + networks: + - banatie-internal + depends_on: + banatie-minio: + condition: service_healthy + env_file: + - /opt/banatie/secrets.env + entrypoint: + - /bin/sh + - -c + - | + echo '=== MinIO Storage Initialization ===' + + echo 'Setting up MinIO alias...' + mc alias set storage http://banatie-minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} + + echo 'Creating main bucket...' + mc mb --ignore-existing storage/banatie + + echo 'Creating service account...' + mc admin user add storage $${MINIO_ACCESS_KEY} $${MINIO_SECRET_KEY} || echo 'User may already exist' + + echo 'Attaching readwrite policy...' + mc admin policy attach storage readwrite --user=$${MINIO_ACCESS_KEY} || echo 'Policy may already be attached' + + echo 'Setting up lifecycle policy for temp cleanup...' + cat > /tmp/lifecycle.json <<'EOF' + { + "Rules": [ + { + "ID": "temp-cleanup", + "Status": "Enabled", + "Filter": {"Prefix": "temp/"}, + "Expiration": {"Days": 7} + } + ] + } + EOF + mc ilm import storage/banatie < /tmp/lifecycle.json || echo 'Lifecycle policy may already exist' + + echo '=== Storage Initialization Completed ===' + echo 'Bucket: banatie' + echo 'Service User: configured' + echo 'Lifecycle: 7-day temp cleanup' + exit 0 + restart: "no" + +# =========================================== +# Networks +# =========================================== +networks: + banatie-internal: + driver: bridge + internal: true # 🔒 CRITICAL: No external internet access + + proxy-network: + name: proxy-network + external: true # Existing Caddy network from /opt/services/ +``` + +## Environment Configuration + +### Public Environment (`/opt/banatie/.env`) + +```bash +# =========================================== +# Banatie Production Environment +# =========================================== + +# Application +NODE_ENV=production +PORT=3000 + +# Database (internal Docker hostname) +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@banatie-postgres:5432/${POSTGRES_DB} +POSTGRES_DB=banatie_db +POSTGRES_USER=banatie_user + +# MinIO (internal Docker hostname) +MINIO_ENDPOINT=banatie-minio:9000 +MINIO_BUCKET_NAME=banatie +MINIO_USE_SSL=false + +# Public URLs (for generated links) +MINIO_PUBLIC_URL=https://cdn.banatie.app +API_PUBLIC_URL=https://api.banatie.app + +# CORS +CORS_ORIGIN=https://banatie.app,https://api.banatie.app + +# Multi-tenancy defaults +DEFAULT_ORG_ID=demo +DEFAULT_USER_ID=guest +``` + +### Secrets Environment (`/opt/banatie/secrets.env`) + +**Permissions:** `chmod 600 /opt/banatie/secrets.env` + +```bash +# =========================================== +# Banatie Secrets (NEVER COMMIT TO GIT) +# =========================================== + +# Database Password +POSTGRES_PASSWORD= + +# MinIO Root (Admin Console Access) +MINIO_ROOT_USER=banatie_admin +MINIO_ROOT_PASSWORD= + +# MinIO Service Account (Application Access) +MINIO_ACCESS_KEY=banatie_service +MINIO_SECRET_KEY= + +# AI Services +GEMINI_API_KEY= + +# Security Tokens +JWT_SECRET= +SESSION_SECRET= +``` + +### PostgreSQL Init Script (`/opt/banatie/scripts/init-db.sql`) + +**Minimal script for PostgreSQL 15 permission fix:** + +```sql +-- Banatie PostgreSQL 15 Permission Fix +-- This ensures service user can create tables in public schema + +-- PostgreSQL 15 removed default CREATE privileges on public schema +-- Grant CREATE permission to database owner +GRANT CREATE ON SCHEMA public TO banatie_user; +GRANT ALL ON SCHEMA public TO banatie_user; + +-- Note: Actual table creation is handled by Drizzle ORM +-- Run 'pnpm --filter @banatie/database db:push' after first deployment +``` + +## Caddy Integration + +### Caddy Volume Update + +Add to `/opt/services/compose-files/caddy.yml`: + +```yaml +volumes: + # ... existing volumes ... + - /opt/banatie/logs:/opt/banatie/logs # Banatie access logs +``` + +### Caddyfile Addition + +Add to `/opt/services/configs/caddy/Caddyfile`: + +```caddy +# ============================================ +# BANATIE SERVICES (Isolated Startup) +# ============================================ + +# Landing Page (Next.js standalone) +banatie.app, www.banatie.app { + reverse_proxy banatie-landing:3000 + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + X-XSS-Protection "1; mode=block" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /opt/banatie/logs/landing-access.log { + roll_size 10MiB + roll_keep 5 + } + format json + } +} + +# API Service (Express.js) +api.banatie.app { + reverse_proxy banatie-api:3000 + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + X-XSS-Protection "1; mode=block" + } + + log { + output file /opt/banatie/logs/api-access.log { + roll_size 10MiB + roll_keep 5 + } + format json + } +} + +# MinIO Console (Admin Interface) +storage.banatie.app { + reverse_proxy banatie-minio:9001 + + header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + } + + log { + output file /opt/banatie/logs/storage-access.log { + roll_size 10MiB + roll_keep 5 + } + format json + } +} + +# CDN - MinIO S3 (Public Images) +cdn.banatie.app { + reverse_proxy banatie-minio:9000 + + # CORS for browser access + header { + Access-Control-Allow-Origin "*" + Access-Control-Allow-Methods "GET, HEAD, OPTIONS" + Access-Control-Allow-Headers "Content-Type, Authorization, Range" + Access-Control-Expose-Headers "Content-Length, Content-Range" + } + + # Cache static images (1 year for immutable content) + header /banatie/* { + Cache-Control "public, max-age=31536000, immutable" + } + + log { + output file /opt/banatie/logs/cdn-access.log { + roll_size 50MiB + roll_keep 10 + } + format json + } +} +``` + +## Deployment Process + +### Prerequisites + +- [ ] VPS access configured (`ssh usul-vps`) +- [ ] DNS records configured for banatie.app subdomains +- [ ] Gitea access for source code +- [ ] GEMINI_API_KEY obtained from Google + +### Initial Deployment + +#### Step 1: Clone Source Code + +```bash +ssh usul-vps + +# Create workspace if not exists +mkdir -p ~/workspace/projects +cd ~/workspace/projects + +# Clone from Gitea +git clone ssh://git@git.usul.su:2222/usulpro/banatie-service.git +``` + +#### Step 2: Create Production Directory + +```bash +# Create isolated Banatie directory +sudo mkdir -p /opt/banatie/{scripts,logs/api,logs/landing} +sudo chown -R usul:usul /opt/banatie + +# Create waitlist-logs directory (Docker will set permissions) +mkdir -p /opt/banatie/data/waitlist-logs +``` + +#### Step 3: Create Configuration Files + +```bash +cd /opt/banatie + +# Create docker-compose.yml (copy from this document) +nano docker-compose.yml + +# Create environment files +nano .env +nano secrets.env +chmod 600 secrets.env + +# Create init-db.sql +mkdir -p scripts +nano scripts/init-db.sql +``` + +#### Step 4: Generate Secure Passwords + +```bash +# Generate all required secrets +echo "POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '\n\r ')" >> secrets.env +echo "MINIO_ROOT_USER=banatie_admin" >> secrets.env +echo "MINIO_ROOT_PASSWORD=$(openssl rand -base64 32 | tr -d '\n\r ')" >> secrets.env +echo "MINIO_ACCESS_KEY=banatie_service" >> secrets.env +echo "MINIO_SECRET_KEY=$(openssl rand -base64 32 | tr -d '\n\r ')" >> secrets.env +echo "JWT_SECRET=$(openssl rand -base64 64 | tr -d '\n\r ')" >> secrets.env +echo "SESSION_SECRET=$(openssl rand -base64 32 | tr -d '\n\r ')" >> secrets.env +echo "GEMINI_API_KEY=your_actual_key_here" >> secrets.env + +chmod 600 secrets.env +``` + +#### Step 5: Update Caddy Configuration + +```bash +# Add Banatie volume to Caddy +cd /opt/services +nano compose-files/caddy.yml +# Add: - /opt/banatie/logs:/opt/banatie/logs + +# Add Banatie routes to Caddyfile +nano configs/caddy/Caddyfile +# Add Banatie configuration from this document + +# Restart Caddy to apply changes +docker compose -f compose-files/caddy.yml --env-file .env up -d +``` + +#### Step 6: Remove Old Static Landing (if exists) + +```bash +# Remove old static deployment +sudo rm -rf /var/www/banatie.app +``` + +#### Step 7: Build and Deploy + +```bash +cd /opt/banatie + +# Build all images +docker compose build + +# Start services +docker compose up -d + +# Check status +docker compose ps +docker compose logs -f +``` + +#### Step 8: Initialize Database Schema + +```bash +# After containers are running, push Drizzle schema +cd ~/workspace/projects/banatie-service +pnpm install # if not done +pnpm --filter @banatie/database db:push +``` + +#### Step 9: Verify Deployment + +```bash +# Check health endpoints +curl -f https://banatie.app +curl -f https://api.banatie.app/health +curl -f https://storage.banatie.app +curl -f https://cdn.banatie.app/minio/health/live + +# Check SSL certificates +echo | openssl s_client -servername banatie.app -connect banatie.app:443 2>/dev/null | openssl x509 -noout -dates + +# Check logs +tail -f /opt/banatie/logs/*.log +``` + +### Update Process + +```bash +ssh usul-vps + +# Pull latest code +cd ~/workspace/projects/banatie-service +git pull origin main + +# Rebuild and redeploy +cd /opt/banatie +docker compose build +docker compose up -d --force-recreate + +# Run migrations if schema changed +cd ~/workspace/projects/banatie-service +pnpm --filter @banatie/database db:push + +# Verify +curl -f https://api.banatie.app/health +``` + +## Backup Strategy + +### Database Backup + +```bash +# Create backup +docker exec banatie-postgres pg_dump -U banatie_user banatie_db > /opt/banatie/backups/banatie_db_$(date +%Y%m%d).sql + +# Restore backup +docker exec -i banatie-postgres psql -U banatie_user banatie_db < /opt/banatie/backups/banatie_db_YYYYMMDD.sql +``` + +### MinIO Data Backup + +```bash +# Backup MinIO data (requires sudo due to Docker User NS Remapping) +sudo tar -czf /opt/banatie/backups/minio_$(date +%Y%m%d).tar.gz -C /opt/banatie/data minio/ + +# Restore MinIO data +sudo tar -xzf /opt/banatie/backups/minio_YYYYMMDD.tar.gz -C /opt/banatie/data +``` + +### Full Backup + +```bash +# Complete Banatie backup +sudo tar -czf ~/banatie_full_backup_$(date +%Y%m%d).tar.gz \ + -C /opt banatie/ \ + --exclude='banatie/data/postgres' \ + --exclude='banatie/data/minio' + +# Database backup separately +docker exec banatie-postgres pg_dump -U banatie_user banatie_db > ~/banatie_db_$(date +%Y%m%d).sql +``` + +## Monitoring + +### Health Check Endpoints + +| Endpoint | Expected | Description | +|----------|----------|-------------| +| `https://banatie.app` | 200 | Landing page | +| `https://api.banatie.app/health` | 200 JSON | API health | +| `https://storage.banatie.app` | 200 | MinIO console | +| `https://cdn.banatie.app/minio/health/live` | 200 | MinIO S3 | + +### Log Locations + +| Log | Path | +|-----|------| +| API Access | `/opt/banatie/logs/api-access.log` | +| Landing Access | `/opt/banatie/logs/landing-access.log` | +| Storage Console | `/opt/banatie/logs/storage-access.log` | +| CDN Access | `/opt/banatie/logs/cdn-access.log` | +| API Application | `/opt/banatie/logs/api/` | +| Waitlist Emails | `/opt/banatie/data/waitlist-logs/` | + +### Docker Status + +```bash +# Check all Banatie containers +docker ps --filter "name=banatie" + +# Check resource usage +docker stats --filter "name=banatie" + +# View logs +docker compose -f /opt/banatie/docker-compose.yml logs -f [service] +``` + +## Troubleshooting + +### Container Won't Start + +```bash +# Check logs +docker compose -f /opt/banatie/docker-compose.yml logs [service] + +# Check container status +docker inspect banatie-[service] | jq '.[0].State' + +# Rebuild container +docker compose -f /opt/banatie/docker-compose.yml build [service] +docker compose -f /opt/banatie/docker-compose.yml up -d [service] +``` + +### Database Connection Issues + +```bash +# Test database connectivity +docker exec banatie-postgres pg_isready -U banatie_user -d banatie_db + +# Check environment variables +docker exec banatie-api env | grep -E 'DATABASE|POSTGRES' + +# Connect to database manually +docker exec -it banatie-postgres psql -U banatie_user -d banatie_db +``` + +### MinIO Access Issues + +```bash +# Check MinIO status +docker exec banatie-minio mc admin info local + +# Test S3 API +curl -f http://localhost:9000/minio/health/live + +# Check bucket exists +docker exec banatie-storage-init mc ls storage/ +``` + +### Permission Issues (Docker User NS Remapping) + +```bash +# Check container UID +docker exec banatie-postgres id +# Expected: uid=70(postgres) + +# Calculate host UID and fix +# PostgreSQL: 70 + 165536 = 165606 +sudo chown -R 165606:165606 /opt/banatie/data/postgres + +# MinIO: 1000 + 165536 = 166536 +sudo chown -R 166536:166536 /opt/banatie/data/minio +``` + +### SSL Certificate Issues + +```bash +# Check Caddy logs +docker logs caddy | grep -i "banatie\|certificate\|error" + +# Force certificate renewal +docker exec caddy caddy reload --config /etc/caddy/Caddyfile + +# Check DNS resolution +dig banatie.app +short +dig api.banatie.app +short +``` + +## Security Considerations + +### Network Security + +- ✅ Internal network (`banatie-internal`) has no external access +- ✅ PostgreSQL is not exposed (no `ports:` mapping) +- ✅ All external access via Caddy HTTPS only +- ✅ MinIO S3 API restricted to GET/HEAD for public access + +### Container Security + +- ✅ Non-root users in application containers +- ✅ Docker User Namespace Remapping enabled +- ✅ Resource limits can be added as needed +- ✅ Health checks for automatic restart + +### Data Security + +- ✅ Secrets stored in separate file with 600 permissions +- ✅ Database credentials not exposed +- ✅ MinIO service account has limited permissions +- ✅ JWT/Session secrets properly generated + +### Access Control + +- ✅ API key authentication required +- ✅ Master key for admin operations +- ✅ Rate limiting at Caddy level possible +- ✅ CORS configured for specific origins + +--- + +**Document Version**: 1.0 +**Last Updated**: December 2025 +**Maintained By**: VPS Project (usul.su) +**Related Documentation**: VPS/manuals/banatie-deployment-manual.md diff --git a/prod-env/.env b/prod-env/.env index 819eeef..e6c43a3 100644 --- a/prod-env/.env +++ b/prod-env/.env @@ -49,5 +49,11 @@ UPLOADS_DIR=/app/uploads/temp TTI_LOG=logs/tti-log.md ENH_LOG=logs/enhancing.md +# Waitlist Configuration +# NOTE: WAITLIST_LOGS_PATH is set in docker-compose.yml environment section +# The host path for volume mount should be coordinated with VPS project infrastructure +# Default dev path: ../data/waitlist-logs (relative to prod-env/) +# Production path example: /var/banatie/waitlist-logs + # IMPORTANT: Sensitive values should be in secrets.env (not tracked in git) # See secrets.env.example for required variables diff --git a/prod-env/docker-compose.yml b/prod-env/docker-compose.yml index e9f2e4f..3588d3c 100644 --- a/prod-env/docker-compose.yml +++ b/prod-env/docker-compose.yml @@ -35,6 +35,11 @@ services: container_name: banatie-landing ports: - "3001:3000" + volumes: + # Waitlist email logs + # TODO: Actual host path on VPS must be coordinated with VPS project during production deployment + # Example production path: /var/banatie/waitlist-logs:/app/waitlist-logs + - ../data/waitlist-logs:/app/waitlist-logs networks: - banatie-network depends_on: @@ -45,6 +50,7 @@ services: environment: - IS_DOCKER=true - NODE_ENV=production + - WAITLIST_LOGS_PATH=/app/waitlist-logs restart: unless-stopped # PostgreSQL Database