Checkpoint before follow-up message

Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
Cursor Agent
2026-01-12 14:49:44 +00:00
parent 12245eec8e
commit 9839d1ba7c
10 changed files with 358 additions and 103 deletions

View File

@@ -1,16 +1,16 @@
name: Dev Deployment (Zero Downtime)
name: Testing Deployment (Zero Downtime)
on:
push:
branches: [ dev ]
branches: [ testing ]
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
IMAGE_TAG: staging
IMAGE_TAG: testing
jobs:
deploy-dev:
deploy-testing:
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -38,7 +38,7 @@ jobs:
- name: Build Docker image
run: |
echo "🏗️ Building dev Docker image with BuildKit cache..."
echo "🏗️ Building testing Docker image with BuildKit cache..."
DOCKER_BUILDKIT=1 docker build \
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
@@ -46,12 +46,12 @@ jobs:
.
echo "✅ Docker image built successfully"
- name: Zero-Downtime Dev Deployment
- name: Zero-Downtime Testing Deployment
run: |
echo "🚀 Starting zero-downtime dev deployment..."
echo "🚀 Starting zero-downtime testing deployment..."
COMPOSE_FILE="docker-compose.staging.yml"
CONTAINER_NAME="portfolio-app-staging"
COMPOSE_FILE="docker-compose.testing.yml"
CONTAINER_NAME="portfolio-app-testing"
HEALTH_PORT="3002"
# Backup current container ID if running
@@ -59,7 +59,7 @@ jobs:
# Start new container with updated image
echo "🆕 Starting new dev container..."
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-staging
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-testing
# Wait for new container to be healthy
echo "⏳ Waiting for new container to be healthy..."
@@ -84,8 +84,8 @@ jobs:
# Verify new container is working
if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
echo "⚠️ New dev container health check failed, but continuing (non-blocking)..."
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-staging
echo "⚠️ New testing container health check failed, but continuing (non-blocking)..."
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-testing
fi
# Remove old container if it exists and is different
@@ -98,11 +98,11 @@ jobs:
fi
fi
echo "✅ Dev deployment completed!"
echo "✅ Testing deployment completed!"
env:
NODE_ENV: staging
NODE_ENV: production
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_TESTING || 'https://testing.dk0.dev' }}
MY_EMAIL: ${{ vars.MY_EMAIL }}
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
@@ -111,19 +111,19 @@ jobs:
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
- name: Dev Health Check
- name: Testing Health Check
run: |
echo "🔍 Running dev health checks..."
echo "🔍 Running testing health checks..."
for i in {1..20}; do
if curl -f http://localhost:3002/api/health && curl -f http://localhost:3002/ > /dev/null; then
echo "✅ Dev is fully operational!"
echo "✅ Testing is fully operational!"
exit 0
fi
echo "⏳ Waiting for dev... ($i/20)"
echo "⏳ Waiting for testing... ($i/20)"
sleep 3
done
echo "⚠️ Dev health check failed, but continuing (non-blocking)..."
docker compose -f docker-compose.staging.yml logs --tail=50
echo "⚠️ Testing health check failed, but continuing (non-blocking)..."
docker compose -f docker-compose.testing.yml logs --tail=50
- name: Cleanup
run: |

View File

@@ -196,7 +196,7 @@ jobs:
env:
NODE_ENV: production
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
MY_EMAIL: ${{ vars.MY_EMAIL }}
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}

View File

@@ -10,12 +10,12 @@ In Gitea kannst du **Variables** (öffentlich) und **Secrets** (verschlüsselt)
2. Klicke auf **Settings** (Einstellungen)
3. Klicke auf **Variables** oder **Secrets** im linken Menü
## 🔑 Variablen für Production Branch
## 🔑 Variablen für Production Branch (`production` → `dk0.dev`)
Für den `production` Branch brauchst du:
### Variables (öffentlich sichtbar):
- `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev`
- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
- `MY_EMAIL` = `contact@dk0.dev` (oder deine Email)
- `MY_INFO_EMAIL` = `info@dk0.dev` (oder deine Info-Email)
- `LOG_LEVEL` = `info`
@@ -29,10 +29,10 @@ Für den `production` Branch brauchst du:
## 🧪 Variablen für Dev Branch
Für den `dev` Branch brauchst du die **gleichen** Variablen, aber mit anderen Werten:
Für den `testing` Branch brauchst du die **gleichen** Variablen, aber mit anderen Werten:
### Variables:
- `NEXT_PUBLIC_BASE_URL` = `https://dev.dk0.dev` ⚠️ **WICHTIG: Andere URL!**
- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev` ⚠️ **WICHTIG: Andere URL!**
- `MY_EMAIL` = `contact@dk0.dev` (kann gleich sein)
- `MY_INFO_EMAIL` = `info@dk0.dev` (kann gleich sein)
- `LOG_LEVEL` = `debug` (für Dev mehr Logging)
@@ -41,7 +41,7 @@ Für den `dev` Branch brauchst du die **gleichen** Variablen, aber mit anderen W
### Secrets:
- `MY_PASSWORD` = Dein Email-Passwort (kann gleich sein)
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort (kann gleich sein)
- `ADMIN_BASIC_AUTH` = `admin:staging_password` (kann anders sein)
- `ADMIN_BASIC_AUTH` = `admin:testing_password` (kann anders sein)
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
## ✅ Lösung: Automatische Branch-Erkennung
@@ -54,38 +54,27 @@ Die Workflows triggern auf unterschiedlichen Branches und verwenden automatisch
**Production Workflow** (`.gitea/workflows/production-deploy.yml`):
- Triggert nur auf `production` Branch
- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dk0.dev`
- Verwendet: `NEXT_PUBLIC_BASE_URL_PRODUCTION` (wenn gesetzt) oder Default: `https://dk0.dev`
**Dev Workflow** (`.gitea/workflows/dev-deploy.yml`):
- Triggert nur auf `dev` Branch
- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dev.dk0.dev`
**Testing Workflow** (`.gitea/workflows/dev-deploy.yml`):
- Triggert nur auf `testing` Branch
- Verwendet: `NEXT_PUBLIC_BASE_URL_TESTING` (wenn gesetzt) oder Default: `https://testing.dk0.dev`
**Das bedeutet:**
- Du setzt **eine** Variable `NEXT_PUBLIC_BASE_URL` in Gitea
- **Production Branch** → verwendet diese Variable (oder Default `https://dk0.dev`)
- **Dev Branch** → verwendet diese Variable (oder Default `https://dev.dk0.dev`)
- Du setzt **zwei** Variablen in Gitea (empfohlen, weil Gitea nicht branch-spezifisch scoped):
- `NEXT_PUBLIC_BASE_URL_PRODUCTION`
- `NEXT_PUBLIC_BASE_URL_TESTING`
### ⚠️ WICHTIG:
Da beide Workflows die **gleiche Variable** verwenden, aber unterschiedliche Defaults haben:
**Option 1: Variable NICHT setzen (Empfohlen)**
- Production verwendet automatisch: `https://dk0.dev`
- Dev verwendet automatisch: `https://dev.dk0.dev`
- ✅ Funktioniert perfekt ohne Konfiguration!
**Option 2: Variable setzen**
- Wenn du `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev` setzt
- Dann verwendet **beide** Branches diese URL (nicht ideal für Dev)
- ⚠️ Nicht empfohlen, da Dev dann die Production-URL verwendet
Gitea kann Variablen/Secrets nicht pro Branch trennen. Darum nutzen wir **separate Variablennamen** für Production und Testing.
## ✅ Empfohlene Konfiguration
### ⭐ Einfachste Lösung: NICHTS setzen!
### ⭐ Einfachste Lösung: nur URLs setzen (optional)
Die Workflows haben bereits die richtigen Defaults:
- **Production Branch** → automatisch `https://dk0.dev`
- **Dev Branch** → automatisch `https://dev.dk0.dev`
- **Testing Branch** → automatisch `https://testing.dk0.dev`
Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch!
@@ -102,8 +91,9 @@ Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch!
- `ADMIN_BASIC_AUTH` = `admin:dein_passwort`
- `N8N_SECRET_TOKEN` = Dein n8n Token (optional)
**⚠️ NICHT setzen:**
- `NEXT_PUBLIC_BASE_URL` - Lass diese Variable leer, damit jeder Branch seinen eigenen Default verwendet!
**Optional setzen:**
- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev`
## 📝 Schritt-für-Schritt Anleitung
@@ -116,11 +106,17 @@ https://git.dk0.dev/denshooter/portfolio/settings
### 3. Für Variables (öffentlich):
- Klicke auf **"New Variable"**
- **Name:** `NEXT_PUBLIC_BASE_URL`
- **Value:** `https://dk0.dev` (für Production)
- **Name:** `NEXT_PUBLIC_BASE_URL_PRODUCTION`
- **Value:** `https://dk0.dev`
- **Protect:** ✅ (optional, schützt vor Änderungen)
- Klicke **"Add Variable"**
- Klicke auf **"New Variable"**
- **Name:** `NEXT_PUBLIC_BASE_URL_TESTING`
- **Value:** `https://testing.dk0.dev`
- **Protect:** ✅ (optional)
- Klicke **"Add Variable"**
### 4. Für Secrets (verschlüsselt):
- Klicke auf **"New Secret"**
- **Name:** `MY_PASSWORD`
@@ -133,53 +129,22 @@ Die Workflows verwenden diese einfache Logik:
```yaml
# Production Workflow (triggert nur auf production branch)
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
# Dev Workflow (triggert nur auf dev branch)
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }}
# Testing Workflow (triggert nur auf testing branch)
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_TESTING || 'https://testing.dk0.dev' }}
```
**Das bedeutet:**
- Jeder Workflow hat seinen **eigenen Default**
- Wenn `NEXT_PUBLIC_BASE_URL` in Gitea gesetzt ist, wird diese verwendet
- Wenn die jeweilige Variable (`NEXT_PUBLIC_BASE_URL_PRODUCTION` / `NEXT_PUBLIC_BASE_URL_TESTING`) in Gitea gesetzt ist, wird diese verwendet
- Wenn **nicht** gesetzt, verwendet jeder Branch seinen eigenen Default
**⭐ Beste Lösung:**
- **NICHT** `NEXT_PUBLIC_BASE_URL` in Gitea setzen
- Dann verwendet Production automatisch `https://dk0.dev`
- Und Dev verwendet automatisch `https://dev.dk0.dev`
- ✅ Perfekt getrennt, ohne Konfiguration!
- Setze `NEXT_PUBLIC_BASE_URL_PRODUCTION` und `NEXT_PUBLIC_BASE_URL_TESTING` (oder lass beide weg und nutze Defaults).
## 🎯 Best Practice
1. **Production:** Setze alle Variablen explizit in Gitea
2. **Dev:** Nutze die Defaults im Workflow (oder setze separate Variablen)
3. **Secrets:** Immer in Gitea Secrets setzen, nie in Code!
## 🔍 Prüfen ob Variablen gesetzt sind
In den Workflow-Logs siehst du:
```
📝 Using Gitea Variables and Secrets:
- NEXT_PUBLIC_BASE_URL: https://dk0.dev
```
Wenn eine Variable fehlt, wird der Default verwendet.
## ⚙️ Alternative: Environment-spezifische Variablen
Falls du separate Variablen für Dev und Production willst, können wir die Workflows anpassen:
```yaml
# Production
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
# Dev
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }}
```
Dann könntest du setzen:
- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
- `NEXT_PUBLIC_BASE_URL_DEV` = `https://dev.dk0.dev`
Soll ich die Workflows entsprechend anpassen?
1. **Production (`production`)**: `docker-compose.production.yml` → Port 3000 → NPM Host `dk0.dev`
2. **Testing (`testing`)**: `docker-compose.testing.yml` → Port 3002 → NPM Host `testing.dk0.dev`
3. **Secrets**: immer als Gitea Secrets, nie im Code

View File

@@ -48,8 +48,10 @@ npm run start # Production Server
## 📖 Dokumentation
- [Development Setup](DEV-SETUP.md) - Detaillierte Setup-Anleitung
- [Deployment Guide](DEPLOYMENT.md) - Production Deployment
- [Deployment Setup](DEPLOYMENT_SETUP.md) - Production Deployment
- [Analytics](ANALYTICS.md) - Analytics und Performance
- [CMS Guide](docs/CMS_GUIDE.md) - Inhalte/Sprachen pflegen (Rich Text)
- [Testing & Deployment](docs/TESTING_AND_DEPLOYMENT.md) - Branches → Container → Domains
## 🔗 Links

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/prisma';
import { prisma, projectService } from '@/lib/prisma';
import { requireSessionAuth } from '@/lib/auth';
export async function GET(request: NextRequest) {
@@ -9,16 +9,39 @@ export async function GET(request: NextRequest) {
const authError = requireSessionAuth(request);
if (authError) return authError;
// Get all projects with full data
const projectsResult = await projectService.getAllProjects();
// Projects (with translations)
const projectsResult = await projectService.getAllProjects({ limit: 10000 });
const projects = projectsResult.projects || projectsResult;
const projectIds = projects.map((p: { id: number }) => p.id);
const projectTranslations = await prisma.projectTranslation.findMany({
where: { projectId: { in: projectIds } },
orderBy: [{ projectId: 'asc' }, { locale: 'asc' }],
});
// CMS content pages (with translations)
const contentPages = await prisma.contentPage.findMany({
orderBy: { key: 'asc' },
include: {
translations: {
orderBy: { locale: 'asc' },
},
},
});
const siteSettings = await prisma.siteSettings.findUnique({ where: { id: 1 } });
// Format for export
const exportData = {
version: '1.0',
version: '2.0',
exportDate: new Date().toISOString(),
siteSettings,
contentPages,
projectTranslations,
projects: projects.map(project => ({
id: project.id,
slug: (project as unknown as { slug?: string }).slug,
defaultLocale: (project as unknown as { defaultLocale?: string }).defaultLocale,
title: project.title,
description: project.description,
content: project.content,

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/prisma';
import { prisma, projectService } from '@/lib/prisma';
import { requireSessionAuth } from '@/lib/auth';
export async function POST(request: NextRequest) {
@@ -25,10 +25,65 @@ export async function POST(request: NextRequest) {
errors: [] as string[]
};
// Import SiteSettings (optional)
if (body.siteSettings && typeof body.siteSettings === 'object') {
try {
await prisma.siteSettings.upsert({
where: { id: 1 },
create: { id: 1, ...(body.siteSettings as Record<string, unknown>) } as any,
update: { ...(body.siteSettings as Record<string, unknown>) } as any,
});
} catch {
// non-blocking
}
}
// Import CMS content pages (optional)
if (Array.isArray(body.contentPages)) {
for (const page of body.contentPages) {
try {
if (!page?.key) continue;
const upserted = await prisma.contentPage.upsert({
where: { key: page.key },
create: { key: page.key, status: page.status || 'PUBLISHED' },
update: { status: page.status || 'PUBLISHED' },
});
if (Array.isArray(page.translations)) {
for (const tr of page.translations) {
if (!tr?.locale || !tr?.content) continue;
await prisma.contentPageTranslation.upsert({
where: { pageId_locale: { pageId: upserted.id, locale: tr.locale } },
create: {
pageId: upserted.id,
locale: tr.locale,
title: tr.title || null,
slug: tr.slug || null,
content: tr.content,
metaDescription: tr.metaDescription || null,
keywords: tr.keywords || null,
} as any,
update: {
title: tr.title || null,
slug: tr.slug || null,
content: tr.content,
metaDescription: tr.metaDescription || null,
keywords: tr.keywords || null,
} as any,
});
}
}
} catch (error) {
results.errors.push(`Failed to import content page "${page?.key}": ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
// Preload existing titles once (avoid O(n^2) DB reads during import)
const existingProjectsResult = await projectService.getAllProjects({ limit: 10000 });
const existingProjects = existingProjectsResult.projects || existingProjectsResult;
const existingTitles = new Set(existingProjects.map(p => p.title));
const existingSlugs = new Set(existingProjects.map(p => (p as unknown as { slug?: string }).slug).filter(Boolean));
// Process each project
for (const projectData of body.projects) {
@@ -43,7 +98,9 @@ export async function POST(request: NextRequest) {
}
// Create new project
await projectService.createProject({
const created = await projectService.createProject({
slug: projectData.slug,
defaultLocale: projectData.defaultLocale || 'en',
title: projectData.title,
description: projectData.description,
content: projectData.content,
@@ -76,8 +133,49 @@ export async function POST(request: NextRequest) {
}
});
// Import translations (optional, from export v2)
if (Array.isArray(body.projectTranslations)) {
for (const tr of body.projectTranslations) {
if (!tr?.projectId || !tr?.locale) continue;
// Map translation to created project by original slug/title when possible.
// We match by slug if available in exported project list; otherwise by title.
const exportedProject = body.projects.find((p: any) => p.id === tr.projectId);
const exportedSlug = exportedProject?.slug;
const matches =
(exportedSlug && (created as any).slug === exportedSlug) ||
(!!exportedProject?.title && (created as any).title === exportedProject.title);
if (!matches) continue;
if (!tr.title || !tr.description) continue;
await prisma.projectTranslation.upsert({
where: { projectId_locale: { projectId: (created as any).id, locale: tr.locale } },
create: {
projectId: (created as any).id,
locale: tr.locale,
title: tr.title,
description: tr.description,
content: tr.content || null,
metaDescription: tr.metaDescription || null,
keywords: tr.keywords || null,
ogImage: tr.ogImage || null,
schema: tr.schema || null,
} as any,
update: {
title: tr.title,
description: tr.description,
content: tr.content || null,
metaDescription: tr.metaDescription || null,
keywords: tr.keywords || null,
ogImage: tr.ogImage || null,
schema: tr.schema || null,
} as any,
});
}
}
results.imported++;
existingTitles.add(projectData.title);
if (projectData.slug) existingSlugs.add(projectData.slug);
} catch (error) {
results.skipped++;
results.errors.push(`Failed to import "${projectData.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);

View File

@@ -36,7 +36,7 @@ export default function ImportExport() {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `portfolio-projects-${new Date().toISOString().split('T')[0]}.json`;
a.download = `portfolio-backup-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
@@ -119,9 +119,9 @@ export default function ImportExport() {
<div className="space-y-4">
{/* Export Section */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-4">
<h4 className="font-medium text-stone-900 mb-2">Export Projekte</h4>
<h4 className="font-medium text-stone-900 mb-2">Backup Export (Projekte + CMS)</h4>
<p className="text-sm text-stone-600 mb-3">
Alle Projekte als JSON-Datei herunterladen
Vollständiges Backup als JSON herunterladen (inkl. CMS Inhalte und Übersetzungen)
</p>
<button
onClick={handleExport}
@@ -135,9 +135,9 @@ export default function ImportExport() {
{/* Import Section */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-4">
<h4 className="font-medium text-stone-900 mb-2">Import Projekte</h4>
<h4 className="font-medium text-stone-900 mb-2">Backup Import</h4>
<p className="text-sm text-stone-600 mb-3">
JSON-Datei mit Projekten hochladen
JSON-Datei mit Backup hochladen (Projekte + CMS + Übersetzungen)
</p>
<label className="flex items-center px-4 py-2 bg-stone-900 text-white rounded-lg hover:bg-stone-800 transition-colors cursor-pointer w-fit">
<Upload className="w-4 h-4 mr-2" />

View File

@@ -0,0 +1,95 @@
# Testing Docker Compose configuration for testing.dk0.dev
# Runs alongside production with isolated DB/Redis and different ports.
services:
portfolio-testing:
image: portfolio-app:testing
container_name: portfolio-app-testing
restart: unless-stopped
ports:
- "3002:3000" # Nginx Proxy Manager -> http://HOST:3002
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://portfolio_user:portfolio_testing_pass@postgres-testing:5432/portfolio_testing_db?schema=public
- REDIS_URL=redis://redis-testing:6379
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://testing.dk0.dev}
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
- MY_PASSWORD=${MY_PASSWORD}
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:testing_password}
- LOG_LEVEL=info
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}
- N8N_API_KEY=${N8N_API_KEY:-}
volumes:
- portfolio_testing_data:/app/.next/cache
networks:
- portfolio_testing_net
- proxy
depends_on:
postgres-testing:
condition: service_healthy
redis-testing:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
postgres-testing:
image: postgres:16-alpine
container_name: portfolio-postgres-testing
restart: unless-stopped
environment:
- POSTGRES_DB=portfolio_testing_db
- POSTGRES_USER=portfolio_user
- POSTGRES_PASSWORD=portfolio_testing_pass
volumes:
- postgres_testing_data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
networks:
- portfolio_testing_net
ports:
- "5435:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_testing_db"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
redis-testing:
image: redis:7-alpine
container_name: portfolio-redis-testing
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_testing_data:/data
networks:
- portfolio_testing_net
ports:
- "6382:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
volumes:
portfolio_testing_data:
driver: local
postgres_testing_data:
driver: local
redis_testing_data:
driver: local
networks:
portfolio_testing_net:
driver: bridge
proxy:
external: true

20
docs/CMS_GUIDE.md Normal file
View File

@@ -0,0 +1,20 @@
# CMS Guide (ohne extra Software)
Du brauchst **kein externes CMS**: das Projekt hat ein eingebautes, self-hosted CMS (Postgres + Admin UI).
## Wo ist das CMS?
- Öffne: `/manage`
- Login (Admin)
- Tab: **Content**
## Wie bearbeite ich Texte?
Im Content Tab kannst du auswählen:
- **Page key** (z.B. `home-hero`, `home-about`, `home-contact`, `privacy-policy`, `legal-notice`)
- **Locale** (`en` oder `de`)
Dann:
- Text bearbeiten (Rich Text)
- **Save**

View File

@@ -0,0 +1,52 @@
# Testing & Deployment (Gitea → Docker → Nginx Proxy Manager)
## Ziel
- **Production**: Branch `production` → Container `portfolio-app``dk0.dev` (Port `3000`)
- **Testing**: Branch `testing` → Container `portfolio-app-testing``testing.dk0.dev` (Port `3002`)
Beide Stacks laufen parallel und sind komplett getrennt (eigene Postgres/Redis/Volumes).
## DNS / Nginx Proxy Manager
### DNS
- Setze `A` (oder `CNAME`) Records:
- `dk0.dev` → dein Server
- `testing.dk0.dev` → dein Server
### Nginx Proxy Manager
Lege zwei Proxy Hosts an:
- **`dk0.dev`**
- Forward Hostname/IP: `127.0.0.1` (oder Server-IP)
- Forward Port: `3000`
- **`testing.dk0.dev`**
- Forward Hostname/IP: `127.0.0.1` (oder Server-IP)
- Forward Port: `3002`
Dann SSL Zertifikate (Lets Encrypt) aktivieren.
## Gitea Workflows
- `production` push → `.gitea/workflows/production-deploy.yml`
- `testing` push → `.gitea/workflows/dev-deploy.yml` (umbenannt im Namen, Inhalt ist Testing)
### Benötigte Variables (Gitea)
- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev`
- optional: `MY_EMAIL`, `MY_INFO_EMAIL`, `LOG_LEVEL`, `N8N_WEBHOOK_URL`, `N8N_API_KEY`
### Benötigte Secrets (Gitea)
- `MY_PASSWORD`
- `MY_INFO_PASSWORD`
- `ADMIN_BASIC_AUTH` (z.B. `admin:<starkes_passwort>`)
- optional: `N8N_SECRET_TOKEN`
## Docker Compose Files
- Production: `docker-compose.production.yml` (Port 3000)
- Testing: `docker-compose.testing.yml` (Port 3002)
Wenn du “dev” nicht mehr brauchst, kannst du den Branch einfach nicht mehr benutzen.