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() {
-
+
+ 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