Merge branch 'feature/homepage'
This commit is contained in:
commit
35d28bca80
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue