Merge branch 'feature/homepage'

This commit is contained in:
Oleg Proskurin 2025-12-14 16:15:32 +07:00
commit 35d28bca80
9 changed files with 1247 additions and 29 deletions

View File

@ -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

View File

@ -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

View File

@ -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": {

View File

@ -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<HTMLInputElement>) => {
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() {
</p>
<GlowEffect>
<form
onSubmit={handleSubmit}
className="flex flex-col sm:flex-row gap-2 rounded-[10px] p-1.5 sm:pl-3"
style={{ background: 'rgba(10, 6, 18, 0.95)' }}
>
<input
type="email"
value={email}
onChange={(e) => 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]"
/>
<button
type="submit"
className="px-6 py-3 bg-gradient-to-br from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 rounded-md text-white font-semibold cursor-pointer transition-all whitespace-nowrap"
{isSubmitted ? (
<div
className="flex items-center justify-center gap-3 px-4 py-3 rounded-[10px]"
style={{ background: 'rgba(10, 6, 18, 0.95)' }}
>
Get Early Access
</button>
</form>
<div className="success-checkmark-ring">
<div className="success-checkmark-inner">
<Check className="w-3 h-3 text-white" strokeWidth={3} />
</div>
</div>
<span className="text-white font-medium">Done! You're in the list</span>
</div>
) : (
<form
onSubmit={handleSubmit}
className="flex flex-col sm:flex-row gap-2 rounded-[10px] p-1.5 sm:pl-3"
style={{ background: 'rgba(10, 6, 18, 0.95)' }}
>
<input
type="email"
value={email}
onChange={handleEmailChange}
placeholder="your@email.com"
className={`flex-1 px-4 py-3 bg-transparent border-none rounded-md outline-none placeholder:text-white/40 focus:bg-white/[0.03] ${
isInvalid ? 'text-red-400' : 'text-white'
}`}
/>
<button
type="submit"
disabled={!isEnabled}
className={`hero-btn px-6 py-3 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-md text-white font-semibold transition-all whitespace-nowrap ${
isEnabled ? 'enabled hover:from-indigo-600 hover:to-purple-600' : 'disabled'
}`}
>
Get Early Access
</button>
</form>
)}
</GlowEffect>
<p className="text-sm text-gray-500 mb-20">Free early access. No credit card required.</p>
@ -125,7 +204,7 @@ export function HeroSection() {
</div>
<WaitlistPopup
isOpen={showPopup}
onClose={() => setShowPopup(false)}
onClose={handlePopupClose}
onSubmit={handleWaitlistSubmit}
/>
</section>

View File

@ -0,0 +1,50 @@
'use server';
import { storeMail, updateMail } from '@/lib/logger/waitlistLogger';
const submissionTimestamps = new Map<string, number[]>();
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' };
}
}

View File

@ -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<void> {
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<string> {
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<string | null> {
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<void> {
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<void> {
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<void> {
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');
}

947
infrastructure-v1.md Normal file
View File

@ -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=<generated_secure_password_32_chars>
# MinIO Root (Admin Console Access)
MINIO_ROOT_USER=banatie_admin
MINIO_ROOT_PASSWORD=<generated_secure_password_32_chars>
# MinIO Service Account (Application Access)
MINIO_ACCESS_KEY=banatie_service
MINIO_SECRET_KEY=<generated_secure_password_32_chars>
# AI Services
GEMINI_API_KEY=<your_google_gemini_api_key>
# Security Tokens
JWT_SECRET=<generated_64_char_secret>
SESSION_SECRET=<generated_32_char_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

View File

@ -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

View File

@ -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