Add full stack configuration

- docker-compose.yml with all services
- .env.example with placeholder secrets
- Landing page HTML
- Gitea→OpenProject webhook script
- Comprehensive README with architecture docs
This commit is contained in:
Dotty 2026-03-28 19:04:45 +01:00
commit 6e6ff63f08
6 changed files with 1425 additions and 0 deletions

49
.env.example Normal file
View File

@ -0,0 +1,49 @@
# =============================================================================
# Collaboration Stack - Production Environment
# =============================================================================
# Copy to .env and fill in real values
# Domain
DOMAIN=toppyr.de
# Let's Encrypt
ACME_EMAIL=mail@example.com
# Traefik Dashboard (htpasswd format: user:hash)
TRAEFIK_AUTH=admin:$apr1$...
# Keycloak
KEYCLOAK_DB_PASSWORD=<generate-secure-password>
KEYCLOAK_ADMIN_PASSWORD=<generate-secure-password>
# OIDC Client Secret (shared across services)
OIDC_CLIENT_SECRET=<generate-secure-password>
# OpenProject
OPENPROJECT_SECRET=<generate-64-char-hex>
OPENPROJECT_ADMIN_PASSWORD=<generate-secure-password>
# XWiki
XWIKI_DB_ROOT_PASSWORD=<generate-secure-password>
XWIKI_DB_PASSWORD=<generate-secure-password>
# Nextcloud
NEXTCLOUD_DB_PASSWORD=<generate-secure-password>
NEXTCLOUD_ADMIN_PASSWORD=<generate-secure-password>
# EspoCRM
ESPOCRM_DB_PASSWORD=<generate-secure-password>
ESPOCRM_ADMIN_PASSWORD=<generate-secure-password>
# Mautic
MAUTIC_DB_PASSWORD=<generate-secure-password>
# FreeScout
FREESCOUT_DB_PASSWORD=<generate-secure-password>
FREESCOUT_ADMIN_PASSWORD=<generate-secure-password>
# Gitea
GITEA_DB_PASSWORD=<generate-secure-password>
# OpenProject API (for Gitea webhook)
OPENPROJECT_API_KEY=<generate-from-openproject>

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Secrets - never commit!
.env
# Local development
*.log
*.bak
.DS_Store

154
README.md Normal file
View File

@ -0,0 +1,154 @@
# Toppyr Stack
Docker Compose Stack für **toppyr.de** — Self-Hosted Collaboration Suite.
## 🏗️ Architektur
```
┌─────────────────────────────────────────────────────────┐
│ Traefik (Reverse Proxy) │
│ Let's Encrypt SSL Termination │
└─────────────────────────────────────────────────────────┘
┌───────────────────────────┼───────────────────────────┐
│ Keycloak SSO │
│ sso.toppyr.de │
└───────────────────────────┼───────────────────────────┘
┌───────────────────────┼───────────────────────────┐
│ │ │ │ │
┌───▼───┐ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ ┌───────▼───────┐
│ Gitea │ │OpenProj.│ │ XWiki │ │ Postiz │ │ Monitoring │
│ repo. │ │ project.│ │ wiki. │ │ postiz. │ │ status./netd. │
└───────┘ └─────────┘ └─────────┘ └─────────┘ └───────────────┘
```
## 📦 Services
| Service | URL | Beschreibung |
|---------|-----|--------------|
| **Keycloak** | sso.toppyr.de | Single Sign-On, Identity Provider |
| **OpenProject** | project.toppyr.de | Projektmanagement, Tasks |
| **XWiki** | wiki.toppyr.de | Dokumentation, Wiki |
| **Gitea** | repo.toppyr.de | Git Repositories, CI/CD |
| **Postiz** | postiz.toppyr.de | Social Media Scheduler |
| **EspoCRM** | crm.toppyr.de | CRM System |
| **Mautic** | marketing.toppyr.de | Marketing Automation |
| **FreeScout** | support.toppyr.de | Helpdesk, Ticketing |
| **Nextcloud** | cloud.toppyr.de | Dateiverwaltung |
| **Uptime Kuma** | status.toppyr.de | Monitoring, Alerts |
| **Netdata** | netdata.toppyr.de | System Metrics |
| **lldap** | ldap.toppyr.de | LDAP Directory |
## 🚀 Deployment
### Voraussetzungen
- Docker & Docker Compose v2
- Domain mit DNS-Einträgen auf Server-IP
- Ports 80, 443, 2222 (SSH für Gitea) offen
### Installation
```bash
# Repository klonen
git clone https://repo.toppyr.de/tobias/toppyr-stack.git
cd toppyr-stack
# Environment konfigurieren
cp .env.example .env
# Passwörter generieren und eintragen
openssl rand -base64 32 # Für jedes Passwort-Feld
# Stack starten
docker compose up -d
```
### DNS-Einträge
Alle Subdomains als A-Record auf die Server-IP:
```
@, www, sso, wiki, project, status, crm, marketing, support,
postiz, ldap, netdata, gruen, cloud, repo → <SERVER_IP>
```
## 🔐 Authentifizierung
- **SSO via Keycloak** für: OpenProject, XWiki, Postiz, Gitea, Netdata
- **LDAP via lldap** für: OpenProject (read-only)
- **Standalone** für: EspoCRM, Mautic, FreeScout, Uptime Kuma
## 📁 Verzeichnisstruktur
```
toppyr-stack/
├── docker-compose.yml # Hauptkonfiguration
├── .env.example # Umgebungsvariablen-Template
├── .env # Echte Secrets (nicht committed!)
├── landing/
│ └── index.html # Landing Page toppyr.de
└── webhooks/
└── gitea-openproject-webhook.py # Git→OpenProject Integration
```
## 🔗 Integrationen
### Gitea → OpenProject Webhook
Commits mit `WP-123` im Message werden automatisch als Kommentar im Work Package gepostet.
```bash
# Beispiel Commit
git commit -m "WP-42: Feature implementiert"
```
### XWiki Git Macro
Code aus Gitea-Repos direkt im Wiki einbetten:
```wiki
{{git repository="https://repo.toppyr.de/tobias/toppyr-stack.git"
file="docker-compose.yml"
branch="main"}}
```
## 🧹 Wartung
```bash
# Logs anzeigen
docker compose logs -f <service>
# Service neustarten
docker compose restart <service>
# Updates
docker compose pull
docker compose up -d
# Backup Volumes
docker run --rm -v toppyr-stack_<volume>:/data -v $(pwd):/backup \
alpine tar czf /backup/<volume>.tar.gz /data
```
## 📊 Ressourcen
Getestete RAM-Limits (gesamt ~8-10 GB):
| Service | RAM Limit |
|---------|-----------|
| Keycloak | 2 GB |
| OpenProject | 4 GB |
| XWiki | 4 GB |
| Gitea | 512 MB |
| Postiz | 4 GB |
| Elasticsearch (Temporal) | 1 GB |
| Andere | 64-512 MB |
## 📝 Lizenz
Private Konfiguration für toppyr.de.
---
*Erstellt: März 2026*

885
docker-compose.yml Normal file
View File

@ -0,0 +1,885 @@
# =============================================================================
# Collaboration Stack - PRODUCTION
# =============================================================================
# RAM-Limits nach Empfehlung gesetzt (23.03.2026)
# Gesamt-RAM-Bedarf: ~13-14 GB (nach Entwickler-Empfehlungen)
# =============================================================================
services:
# =============================================================================
# TRAEFIK - Reverse Proxy mit Let's Encrypt
# =============================================================================
traefik:
image: traefik:v2.11
container_name: traefik
deploy:
resources:
limits:
memory: 64m
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--log.level=INFO"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_letsencrypt:/letsencrypt
networks:
- collaboration
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=traefik-auth"
- "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_AUTH}"
# =============================================================================
# KEYCLOAK - Identity Provider (SSO)
# =============================================================================
keycloak-db:
image: postgres:16-alpine
container_name: keycloak-db
deploy:
resources:
limits:
memory: 256m
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
volumes:
- keycloak_db_data:/var/lib/postgresql/data
networks:
- collaboration
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 10s
timeout: 5s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:24.0
container_name: keycloak
deploy:
resources:
limits:
memory: 2g
command: start
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KC_HOSTNAME: sso.${DOMAIN}
KC_PROXY: edge
KC_HTTP_ENABLED: "true"
depends_on:
keycloak-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.keycloak.rule=Host(`sso.${DOMAIN}`)"
- "traefik.http.routers.keycloak.entrypoints=websecure"
- "traefik.http.routers.keycloak.tls.certresolver=letsencrypt"
- "traefik.http.services.keycloak.loadbalancer.server.port=8080"
networks:
- collaboration
restart: unless-stopped
# =============================================================================
# OPENPROJECT - Projektmanagement
# =============================================================================
openproject:
image: openproject/openproject:14
container_name: openproject
deploy:
resources:
limits:
memory: 4g
environment:
OPENPROJECT_SECRET_KEY_BASE: ${OPENPROJECT_SECRET}
OPENPROJECT_HOST__NAME: project.${DOMAIN}
OPENPROJECT_HTTPS: "true"
OPENPROJECT_DEFAULT__LANGUAGE: de
OPENPROJECT_SEED_ADMIN_USER_PASSWORD: ${OPENPROJECT_ADMIN_PASSWORD}
OPENPROJECT_OPENID__CONNECT_KEYCLOAK_DISPLAY__NAME: "SSO Login"
OPENPROJECT_OPENID__CONNECT_KEYCLOAK_HOST: "sso.${DOMAIN}"
OPENPROJECT_OPENID__CONNECT_KEYCLOAK_IDENTIFIER: "openproject"
OPENPROJECT_OPENID__CONNECT_KEYCLOAK_SECRET: ${OIDC_CLIENT_SECRET}
OPENPROJECT_OPENID__CONNECT_KEYCLOAK_ISSUER: "https://sso.${DOMAIN}/realms/collaboration"
OPENPROJECT_OPENID__CONNECT_KEYCLOAK_AUTHORIZATION__ENDPOINT: "https://sso.${DOMAIN}/realms/collaboration/protocol/openid-connect/auth"
OPENPROJECT_OPENID__CONNECT_KEYCLOAK_TOKEN__ENDPOINT: "https://sso.${DOMAIN}/realms/collaboration/protocol/openid-connect/token"
OPENPROJECT_OPENID__CONNECT_KEYCLOAK_USERINFO__ENDPOINT: "https://sso.${DOMAIN}/realms/collaboration/protocol/openid-connect/userinfo"
volumes:
- openproject_data:/var/openproject/assets
- openproject_pgdata:/var/openproject/pgdata
labels:
- "traefik.enable=true"
- "traefik.http.routers.openproject.rule=Host(`project.${DOMAIN}`)"
- "traefik.http.routers.openproject.entrypoints=websecure"
- "traefik.http.routers.openproject.tls.certresolver=letsencrypt"
- "traefik.http.services.openproject.loadbalancer.server.port=80"
networks:
- collaboration
restart: unless-stopped
# =============================================================================
# XWIKI - Wiki
# =============================================================================
xwiki-db:
image: mysql:8.0
container_name: xwiki-db
deploy:
resources:
limits:
memory: 1g
environment:
MYSQL_ROOT_PASSWORD: ${XWIKI_DB_ROOT_PASSWORD}
MYSQL_DATABASE: xwiki
MYSQL_USER: xwiki
MYSQL_PASSWORD: ${XWIKI_DB_PASSWORD}
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
volumes:
- xwiki_db_data:/var/lib/mysql
networks:
- collaboration
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
xwiki:
image: xwiki:16-mysql-tomcat
container_name: xwiki
deploy:
resources:
limits:
memory: 4g
environment:
DB_HOST: xwiki-db
DB_USER: xwiki
DB_PASSWORD: ${XWIKI_DB_PASSWORD}
DB_DATABASE: xwiki
JAVA_OPTS: "-Xmx2g -Xms1g"
volumes:
- xwiki_data:/usr/local/xwiki
depends_on:
xwiki-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.xwiki.rule=Host(`wiki.${DOMAIN}`)"
- "traefik.http.routers.xwiki.entrypoints=websecure"
- "traefik.http.routers.xwiki.tls.certresolver=letsencrypt"
- "traefik.http.services.xwiki.loadbalancer.server.port=8080"
networks:
- collaboration
restart: unless-stopped
# =============================================================================
# NEXTCLOUD - Dateiverwaltung
# =============================================================================
nextcloud-db:
image: postgres:16-alpine
container_name: nextcloud-db
deploy:
resources:
limits:
memory: 256m
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
volumes:
- nextcloud_db_data:/var/lib/postgresql/data
networks:
- collaboration
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nextcloud"]
interval: 10s
timeout: 5s
retries: 5
nextcloud-redis:
image: redis:7-alpine
container_name: nextcloud-redis
deploy:
resources:
limits:
memory: 64m
networks:
- collaboration
restart: unless-stopped
nextcloud:
image: nextcloud:29-apache
container_name: nextcloud
deploy:
resources:
limits:
memory: 512m
environment:
POSTGRES_HOST: nextcloud-db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${NEXTCLOUD_DB_PASSWORD}
REDIS_HOST: nextcloud-redis
NEXTCLOUD_ADMIN_USER: admin
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
NEXTCLOUD_TRUSTED_DOMAINS: "cloud.${DOMAIN}"
OVERWRITEPROTOCOL: https
OVERWRITEHOST: cloud.${DOMAIN}
volumes:
- nextcloud_data:/var/www/html
depends_on:
nextcloud-db:
condition: service_healthy
nextcloud-redis:
condition: service_started
labels:
- "traefik.enable=true"
- "traefik.http.routers.nextcloud.rule=Host(`cloud.${DOMAIN}`)"
- "traefik.http.routers.nextcloud.entrypoints=websecure"
- "traefik.http.routers.nextcloud.tls.certresolver=letsencrypt"
- "traefik.http.services.nextcloud.loadbalancer.server.port=80"
- "traefik.http.routers.nextcloud.middlewares=nextcloud-caldav"
- "traefik.http.middlewares.nextcloud-caldav.redirectregex.permanent=true"
- "traefik.http.middlewares.nextcloud-caldav.redirectregex.regex=^https://(.*)/.well-known/(card|cal)dav"
- "traefik.http.middlewares.nextcloud-caldav.redirectregex.replacement=https://$${1}/remote.php/dav/"
networks:
- collaboration
restart: unless-stopped
# =============================================================================
# LLDAP - Lightweight LDAP Server
# =============================================================================
lldap:
image: lldap/lldap:stable
container_name: lldap
deploy:
resources:
limits:
memory: 128m
environment:
- UID=1000
- GID=1000
- TZ=Europe/Berlin
- LLDAP_JWT_SECRET=ZldD2dpHj3RssRqBwdgAVmjeSu7UfRamg7PCsrl0iw8=
- LLDAP_LDAP_USER_PASS=coHfqoruajDrXzeze6qA
- LLDAP_LDAP_BASE_DN=dc=toppyr,dc=de
volumes:
- lldap_data:/data
networks:
- collaboration
labels:
- "traefik.enable=true"
- "traefik.http.routers.lldap.rule=Host(`ldap.toppyr.de`)"
- "traefik.http.routers.lldap.entrypoints=websecure"
- "traefik.http.routers.lldap.tls.certresolver=letsencrypt"
- "traefik.http.services.lldap.loadbalancer.server.port=17170"
restart: unless-stopped
# =============================================================================
# POSTIZ - Social Media Scheduler
# =============================================================================
postiz:
image: ghcr.io/gitroomhq/postiz-app:latest
container_name: postiz
deploy:
resources:
limits:
memory: 4g
restart: unless-stopped
environment:
MAIN_URL: "https://postiz.toppyr.de"
FRONTEND_URL: "https://postiz.toppyr.de"
NEXT_PUBLIC_BACKEND_URL: "https://postiz.toppyr.de/api"
JWT_SECRET: "Xk9mL2pQnRs5tUvWxYz1A3bC4dEf6gHi"
DATABASE_URL: "postgresql://postiz:postiz-secret@postiz-db:5432/postiz"
REDIS_URL: "redis://postiz-redis:6379"
BACKEND_INTERNAL_URL: "http://127.0.0.1:3000"
IS_GENERAL: "true"
LINKEDIN_CLIENT_ID: "78tv4ijhgx8c1e"
LINKEDIN_CLIENT_SECRET: "WPL_AP1.C0UAsznfDj7xWWfs.SDHO8g=="
THREADS_APP_ID: "1641922170163889"
THREADS_APP_SECRET: "dea32c2064e54bf3d58b70e51a330ef9"
MASTODON_CLIENT_ID: "2XmVi5O3Bmv8NRwKiJ6Yi4osjSezLUoRce0zBr2xlAQ"
MASTODON_CLIENT_SECRET: "foi-oiFSzVXvPsMHzsj0LUsR8qZe7VxN5uI-VKzR6IQ"
MASTODON_URL: "https://gruene.social"
DISABLE_REGISTRATION: "true"
STORAGE_PROVIDER: "local"
UPLOAD_DIRECTORY: "/uploads"
POSTIZ_GENERIC_OAUTH: "true"
NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME: "Toppyr SSO"
POSTIZ_OAUTH_URL: "https://sso.toppyr.de"
POSTIZ_OAUTH_AUTH_URL: "https://sso.toppyr.de/realms/collaboration/protocol/openid-connect/auth"
POSTIZ_OAUTH_TOKEN_URL: "https://sso.toppyr.de/realms/collaboration/protocol/openid-connect/token"
POSTIZ_OAUTH_USERINFO_URL: "https://sso.toppyr.de/realms/collaboration/protocol/openid-connect/userinfo"
POSTIZ_OAUTH_CLIENT_ID: "postiz"
POSTIZ_OAUTH_CLIENT_SECRET: "xU7jKdZVrHHfp4VTligJKURgm7OXzkud"
TEMPORAL_ADDRESS: "temporal:7233"
NEXT_PUBLIC_UPLOAD_DIRECTORY: "/uploads"
volumes:
- postiz_config:/config
- postiz_uploads:/uploads
labels:
- "traefik.enable=true"
- "traefik.http.routers.postiz.rule=Host(`postiz.toppyr.de`)"
- "traefik.http.routers.postiz.entrypoints=websecure"
- "traefik.http.routers.postiz.tls.certresolver=letsencrypt"
- "traefik.http.services.postiz.loadbalancer.server.port=5000"
networks:
- collaboration
depends_on:
postiz-db:
condition: service_healthy
postiz-redis:
condition: service_healthy
postiz-db:
image: postgres:17-alpine
container_name: postiz-db
deploy:
resources:
limits:
memory: 256m
restart: unless-stopped
environment:
POSTGRES_DB: postiz
POSTGRES_USER: postiz
POSTGRES_PASSWORD: postiz-secret
volumes:
- postiz_db:/var/lib/postgresql/data
networks:
- collaboration
healthcheck:
test: ["CMD", "pg_isready", "-U", "postiz"]
interval: 10s
timeout: 5s
retries: 5
postiz-redis:
image: redis:7.2-alpine
container_name: postiz-redis
deploy:
resources:
limits:
memory: 64m
restart: unless-stopped
volumes:
- postiz_redis:/data
networks:
- collaboration
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# =============================================================================
# LANDING PAGE
# =============================================================================
landing:
image: nginx:alpine
container_name: landing
deploy:
resources:
limits:
memory: 32m
restart: unless-stopped
volumes:
- /opt/collaboration/landing:/usr/share/nginx/html:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.landing.rule=Host(`toppyr.de`) || Host(`www.toppyr.de`)"
- "traefik.http.routers.landing.entrypoints=websecure"
- "traefik.http.routers.landing.tls.certresolver=letsencrypt"
- "traefik.http.services.landing.loadbalancer.server.port=80"
networks:
- collaboration
# =============================================================================
# GRÜNE ANTRAEGE - Antragstools
# =============================================================================
gruene-antraege:
image: nginx:alpine
container_name: gruene-antraege
deploy:
resources:
limits:
memory: 32m
restart: unless-stopped
volumes:
- /opt/collaboration/gruene-antraege:/usr/share/nginx/html:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.gruene.middlewares=sso-auth@docker"
- "traefik.http.routers.gruene.rule=Host(`gruen.toppyr.de`)"
- "traefik.http.routers.gruene.entrypoints=websecure"
- "traefik.http.routers.gruene.tls.certresolver=letsencrypt"
- "traefik.http.services.gruene.loadbalancer.server.port=80"
networks:
- collaboration
# =============================================================================
# TEMPORAL STACK - Workflow Engine for Postiz
# =============================================================================
temporal-elasticsearch:
container_name: temporal-elasticsearch
image: elasticsearch:7.17.27
deploy:
resources:
limits:
memory: 1g
restart: unless-stopped
environment:
- cluster.routing.allocation.disk.threshold_enabled=true
- cluster.routing.allocation.disk.watermark.low=512mb
- cluster.routing.allocation.disk.watermark.high=256mb
- cluster.routing.allocation.disk.watermark.flood_stage=128mb
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms512m -Xmx512m
- xpack.security.enabled=false
networks:
- collaboration
expose:
- 9200
volumes:
- temporal_es_data:/usr/share/elasticsearch/data
temporal-postgresql:
container_name: temporal-postgresql
image: postgres:16
deploy:
resources:
limits:
memory: 256m
restart: unless-stopped
environment:
POSTGRES_PASSWORD: temporal
POSTGRES_USER: temporal
networks:
- collaboration
expose:
- 5432
volumes:
- temporal_pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "temporal"]
interval: 10s
timeout: 5s
retries: 5
temporal:
container_name: temporal
image: temporalio/auto-setup:1.28.1
deploy:
resources:
limits:
memory: 256m
restart: unless-stopped
depends_on:
temporal-postgresql:
condition: service_healthy
temporal-elasticsearch:
condition: service_started
environment:
- DB=postgres12
- DB_PORT=5432
- POSTGRES_USER=temporal
- POSTGRES_PWD=temporal
- POSTGRES_SEEDS=temporal-postgresql
- ENABLE_ES=true
- ES_SEEDS=temporal-elasticsearch
- ES_VERSION=v7
- TEMPORAL_NAMESPACE=default
networks:
- collaboration
expose:
- 7233
# =============================================================================
# TRAEFIK FORWARD AUTH (Keycloak SSO)
# =============================================================================
forward-auth:
image: thomseddon/traefik-forward-auth:2
container_name: forward-auth
deploy:
resources:
limits:
memory: 64m
restart: unless-stopped
environment:
PROVIDERS_OIDC_ISSUER_URL: "https://sso.toppyr.de/realms/collaboration"
PROVIDERS_OIDC_CLIENT_ID: "gruene-antraege"
PROVIDERS_OIDC_CLIENT_SECRET: "gruene-antraege-secret-2026"
SECRET: "forward-auth-secret-random-string-2026"
DEFAULT_PROVIDER: "oidc"
LOG_LEVEL: "debug"
COOKIE_DOMAIN: "toppyr.de"
URL_PATH: "/_oauth"
labels:
- "traefik.enable=true"
- "traefik.http.middlewares.sso-auth.forwardauth.address=http://forward-auth:4181"
- "traefik.http.middlewares.sso-auth.forwardauth.authResponseHeaders=X-Forwarded-User"
- "traefik.http.middlewares.sso-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.services.forward-auth.loadbalancer.server.port=4181"
# =============================================================================
# =============================================================================
# ESPOCRM - CRM System
# =============================================================================
espocrm-db:
image: mariadb:10.11
container_name: espocrm-db
deploy:
resources:
limits:
memory: 512m
environment:
MARIADB_ROOT_PASSWORD: ${ESPOCRM_DB_PASSWORD}
MARIADB_DATABASE: espocrm
MARIADB_USER: espocrm
MARIADB_PASSWORD: ${ESPOCRM_DB_PASSWORD}
volumes:
- espocrm_db_data:/var/lib/mysql
networks:
- collaboration
restart: unless-stopped
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
espocrm:
image: espocrm/espocrm:latest
container_name: espocrm
deploy:
resources:
limits:
memory: 512m
environment:
ESPOCRM_DATABASE_HOST: espocrm-db
ESPOCRM_DATABASE_NAME: espocrm
ESPOCRM_DATABASE_USER: espocrm
ESPOCRM_DATABASE_PASSWORD: ${ESPOCRM_DB_PASSWORD}
ESPOCRM_ADMIN_USERNAME: admin
ESPOCRM_ADMIN_PASSWORD: ${ESPOCRM_ADMIN_PASSWORD}
ESPOCRM_SITE_URL: https://crm.toppyr.de
volumes:
- espocrm_data:/var/www/html
depends_on:
espocrm-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.espocrm.rule=Host(`crm.toppyr.de`)"
- "traefik.http.routers.espocrm.entrypoints=websecure"
- "traefik.http.routers.espocrm.tls.certresolver=letsencrypt"
- "traefik.http.services.espocrm.loadbalancer.server.port=80"
networks:
- collaboration
restart: unless-stopped
# MAUTIC - Marketing Automation
# =============================================================================
mautic-db:
image: mariadb:10.11
container_name: mautic-db
deploy:
resources:
limits:
memory: 512m
environment:
MARIADB_ROOT_PASSWORD: ${MAUTIC_DB_PASSWORD}
MARIADB_DATABASE: mautic
MARIADB_USER: mautic
MARIADB_PASSWORD: ${MAUTIC_DB_PASSWORD}
volumes:
- mautic_db_data:/var/lib/mysql
networks:
- collaboration
restart: unless-stopped
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
mautic:
image: mautic/mautic:5-apache
container_name: mautic
deploy:
resources:
limits:
memory: 1g
environment:
MAUTIC_DB_HOST: mautic-db
MAUTIC_DB_PORT: 3306
MAUTIC_DB_NAME: mautic
MAUTIC_DB_USER: mautic
MAUTIC_DB_PASSWORD: ${MAUTIC_DB_PASSWORD}
MAUTIC_RUN_CRON_JOBS: "true"
MAUTIC_TRUSTED_PROXIES: "[\"0.0.0.0/0\"]"
volumes:
- mautic_data:/var/www/html
depends_on:
mautic-db:
condition: service_healthy
labels:
- "traefik.enable=true"
#- "traefik.http.routers.mautic.middlewares=sso-auth@docker" # Disabled for native SAML
- "traefik.http.routers.mautic.rule=Host(`marketing.toppyr.de`)"
- "traefik.http.routers.mautic.entrypoints=websecure"
- "traefik.http.routers.mautic.tls.certresolver=letsencrypt"
- "traefik.http.services.mautic.loadbalancer.server.port=80"
networks:
- collaboration
restart: unless-stopped
# =============================================================================
# FREESCOUT - Helpdesk / Ticketsystem
# =============================================================================
freescout-db:
image: mariadb:10.11
container_name: freescout-db
deploy:
resources:
limits:
memory: 256m
environment:
MARIADB_ROOT_PASSWORD: ${FREESCOUT_DB_PASSWORD}
MARIADB_DATABASE: freescout
MARIADB_USER: freescout
MARIADB_PASSWORD: ${FREESCOUT_DB_PASSWORD}
volumes:
- freescout_db_data:/var/lib/mysql
networks:
- collaboration
restart: unless-stopped
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
freescout:
image: tiredofit/freescout:latest
container_name: freescout
deploy:
resources:
limits:
memory: 512m
environment:
CONTAINER_NAME: freescout
DB_HOST: freescout-db
DB_NAME: freescout
DB_USER: freescout
DB_PASS: ${FREESCOUT_DB_PASSWORD}
SITE_URL: https://support.toppyr.de
ADMIN_EMAIL: mail@tobiasroedel.de
ADMIN_PASS: ${FREESCOUT_ADMIN_PASSWORD}
TIMEZONE: Europe/Berlin
ENABLE_SSL_PROXY: "TRUE"
volumes:
- freescout_data:/data
depends_on:
freescout-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.freescout.rule=Host(`support.toppyr.de`)"
- "traefik.http.routers.freescout.entrypoints=websecure"
- "traefik.http.routers.freescout.tls.certresolver=letsencrypt"
- "traefik.http.services.freescout.loadbalancer.server.port=80"
networks:
- collaboration
restart: unless-stopped
# =============================================================================
# UPTIME KUMA - Monitoring
# =============================================================================
# =============================================================================
# NETDATA PROXY (External service on host)
# =============================================================================
netdata-proxy:
image: alpine/socat
container_name: netdata-proxy
restart: unless-stopped
command: TCP-LISTEN:19999,fork,reuseaddr TCP:host.docker.internal:19999
extra_hosts:
- "host.docker.internal:host-gateway"
labels:
- "traefik.enable=true"
- "traefik.http.routers.netdata.rule=Host(`netdata.toppyr.de`)"
- "traefik.http.routers.netdata.entrypoints=websecure"
- "traefik.http.routers.netdata.tls.certresolver=letsencrypt"
- "traefik.http.services.netdata.loadbalancer.server.port=19999"
- "traefik.http.routers.netdata.middlewares=sso-auth@docker"
networks:
- collaboration
# =============================================================================
# MONITORING DASHBOARD (Static HTML)
# =============================================================================
monitoring-dashboard:
image: nginx:alpine
container_name: monitoring-dashboard
restart: unless-stopped
volumes:
- /var/www/monitoring:/usr/share/nginx/html:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.monitoring.priority=100"
- "traefik.http.routers.monitoring.rule=Host(`toppyr.de`) || Host(`www.toppyr.de`)"
- "traefik.http.routers.monitoring.entrypoints=websecure"
- "traefik.http.routers.monitoring.tls.certresolver=letsencrypt"
- "traefik.http.routers.monitoring.middlewares="
- "traefik.http.services.monitoring.loadbalancer.server.port=80"
networks:
- collaboration
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
deploy:
resources:
limits:
memory: 256m
restart: unless-stopped
volumes:
- uptime_kuma_data:/app/data
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.uptime.rule=Host(`status.toppyr.de`)"
- "traefik.http.routers.uptime.entrypoints=websecure"
- "traefik.http.routers.uptime.tls.certresolver=letsencrypt"
- "traefik.http.services.uptime.loadbalancer.server.port=3001"
networks:
- collaboration
# =============================================================================
# GITEA - Git Repository Server
# =============================================================================
gitea-db:
image: postgres:16-alpine
container_name: gitea-db
deploy:
resources:
limits:
memory: 256m
environment:
POSTGRES_DB: gitea
POSTGRES_USER: gitea
POSTGRES_PASSWORD: ${GITEA_DB_PASSWORD}
volumes:
- gitea_db_data:/var/lib/postgresql/data
networks:
- collaboration
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea"]
interval: 10s
timeout: 5s
retries: 5
gitea:
image: gitea/gitea:1.22
container_name: gitea
deploy:
resources:
limits:
memory: 512m
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=${GITEA_DB_PASSWORD}
- GITEA__server__DOMAIN=repo.${DOMAIN}
- GITEA__server__SSH_DOMAIN=repo.${DOMAIN}
- GITEA__server__ROOT_URL=https://repo.${DOMAIN}/
- GITEA__server__SSH_PORT=2222
- GITEA__server__SSH_LISTEN_PORT=22
- GITEA__service__DISABLE_REGISTRATION=true
- GITEA__openid__ENABLE_OPENID_SIGNIN=false
- GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION=true
- GITEA__oauth2_client__USERNAME=nickname
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "2222:22"
depends_on:
gitea-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.gitea.rule=Host(`repo.${DOMAIN}`)"
- "traefik.http.routers.gitea.entrypoints=websecure"
- "traefik.http.routers.gitea.tls.certresolver=letsencrypt"
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
networks:
- collaboration
restart: unless-stopped
# =============================================================================
# WEBHOOK HANDLER - Gitea → OpenProject Integration
# =============================================================================
webhook-handler:
image: python:3.12-alpine
container_name: webhook-handler
deploy:
resources:
limits:
memory: 64m
environment:
- OPENPROJECT_URL=https://project.${DOMAIN}
- OPENPROJECT_API_KEY=${OPENPROJECT_API_KEY:-}
- GITEA_URL=https://repo.${DOMAIN}
- PORT=9000
volumes:
- /opt/collaboration/webhooks:/app:ro
working_dir: /app
command: python gitea-openproject-webhook.py
networks:
- collaboration
restart: unless-stopped
volumes:
gitea_db_data:
gitea_data:
traefik_letsencrypt:
traefik_certs:
keycloak_db_data:
openproject_data:
openproject_pgdata:
xwiki_db_data:
xwiki_data:
nextcloud_db_data:
nextcloud_data:
lldap_data:
postiz_config:
postiz_uploads:
postiz_db:
postiz_redis:
temporal_es_data:
temporal_pg_data:
espocrm_db_data:
espocrm_data:
mautic_db_data:
mautic_data:
freescout_db_data:
freescout_data:
uptime_kuma_data:
networks:
collaboration:
driver: bridge

232
landing/index.html Normal file
View File

@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>toppyr.de — Service Hub</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #eee;
padding: 40px 20px;
}
h1 {
text-align: center;
margin-bottom: 10px;
font-size: 2.5rem;
background: linear-gradient(90deg, #00d4aa, #00a8cc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 40px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
max-width: 1400px;
margin: 0 auto;
}
.card {
background: rgba(22, 33, 62, 0.8);
border-radius: 16px;
padding: 25px;
border: 1px solid rgba(0, 212, 170, 0.2);
transition: all 0.3s ease;
text-decoration: none;
color: inherit;
display: block;
}
.card:hover {
transform: translateY(-5px);
border-color: #00d4aa;
box-shadow: 0 10px 40px rgba(0, 212, 170, 0.2);
}
.card-icon {
font-size: 2.5rem;
margin-bottom: 15px;
}
.card h2 {
font-size: 1.3rem;
margin-bottom: 8px;
color: #fff;
}
.card p {
font-size: 0.9rem;
color: #aaa;
line-height: 1.5;
}
.card .tag {
display: inline-block;
background: rgba(0, 212, 170, 0.2);
color: #00d4aa;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
margin-top: 12px;
}
.section-title {
grid-column: 1 / -1;
font-size: 1rem;
color: #e94560;
margin: 30px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(233, 69, 96, 0.3);
text-transform: uppercase;
letter-spacing: 2px;
}
.section-title:first-of-type {
margin-top: 0;
}
.status-bar {
display: flex;
justify-content: center;
gap: 40px;
margin-bottom: 40px;
flex-wrap: wrap;
}
.status-item {
text-align: center;
}
.status-item .value {
font-size: 2rem;
font-weight: bold;
color: #00d4aa;
}
.status-item .label {
font-size: 0.8rem;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
}
footer {
text-align: center;
margin-top: 50px;
color: #555;
font-size: 0.85rem;
}
footer a { color: #00d4aa; text-decoration: none; }
</style>
</head>
<body>
<h1>toppyr.de</h1>
<p class="subtitle">Collaboration & Infrastructure Hub</p>
<div class="status-bar">
<div class="status-item">
<div class="value">26</div>
<div class="label">Services</div>
</div>
<div class="status-item">
<div class="value"></div>
<div class="label">All Systems</div>
</div>
</div>
<div class="grid">
<div class="section-title">🔧 Collaboration</div>
<a href="https://crm.toppyr.de" class="card">
<div class="card-icon">👥</div>
<h2>CRM</h2>
<p>Kontakte, Leads und Kundenbeziehungen verwalten</p>
<span class="tag">EspoCRM</span>
</a>
<a href="https://project.toppyr.de" class="card">
<div class="card-icon">📋</div>
<h2>Projekte</h2>
<p>Projektmanagement, Tasks und Zeiterfassung</p>
<span class="tag">OpenProject</span>
</a>
<a href="https://wiki.toppyr.de" class="card">
<div class="card-icon">📚</div>
<h2>Wiki</h2>
<p>Dokumentation und Wissensmanagement</p>
<span class="tag">XWiki</span>
</a>
<a href="https://support.toppyr.de" class="card">
<div class="card-icon">🎫</div>
<h2>Support</h2>
<p>Helpdesk und Ticket-System</p>
<span class="tag">FreeScout</span>
</a>
<a href="https://marketing.toppyr.de" class="card">
<div class="card-icon">📧</div>
<h2>Marketing</h2>
<p>E-Mail-Kampagnen und Automation</p>
<span class="tag">Mautic</span>
</a>
<a href="https://postiz.toppyr.de" class="card">
<div class="card-icon">📱</div>
<h2>Social Media</h2>
<p>Cross-Posting und Scheduling</p>
<span class="tag">Postiz</span>
</a>
<div class="section-title">💻 Development</div>
<a href="https://repo.toppyr.de" class="card">
<div class="card-icon">🦊</div>
<h2>Git</h2>
<p>Code-Repositories, Issues und CI/CD</p>
<span class="tag">Gitea</span>
</a>
<div class="section-title">🔐 Administration</div>
<a href="https://sso.toppyr.de" class="card">
<div class="card-icon">🔑</div>
<h2>SSO</h2>
<p>Single Sign-On und Benutzerverwaltung</p>
<span class="tag">Keycloak</span>
</a>
<a href="https://ldap.toppyr.de" class="card">
<div class="card-icon">👤</div>
<h2>LDAP</h2>
<p>Verzeichnisdienst</p>
<span class="tag">lldap</span>
</a>
<div class="section-title">📊 Monitoring</div>
<a href="https://status.toppyr.de" class="card">
<div class="card-icon">💚</div>
<h2>Uptime</h2>
<p>Service-Status und Alerts</p>
<span class="tag">Uptime Kuma</span>
</a>
<a href="https://netdata.toppyr.de" class="card">
<div class="card-icon">📈</div>
<h2>Metrics</h2>
<p>Server-Performance und Echtzeit-Metriken</p>
<span class="tag">Netdata</span>
</a>
<div class="section-title">🗃️ Daten</div>
<a href="https://gruen.toppyr.de" class="card">
<div class="card-icon">🌱</div>
<h2>Grüne Anträge</h2>
<p>ALLRIS-Datenbank Hagen</p>
<span class="tag">Datasette</span>
</a>
</div>
<footer>
<p>VServer 152.53.119.77 · 16 GB RAM · 8 Cores · Debian 13</p>
</footer>
</body>
</html>

View File

@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
Gitea OpenProject Webhook Handler
Parses commit messages for WP-XXX patterns and adds comments to Work Packages
"""
import os
import re
import json
import base64
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler
OPENPROJECT_URL = os.getenv("OPENPROJECT_URL", "https://project.toppyr.de")
OPENPROJECT_API_KEY = os.getenv("OPENPROJECT_API_KEY", "")
GITEA_URL = os.getenv("GITEA_URL", "https://git.toppyr.de")
WP_PATTERN = re.compile(r"WP-(\d+)", re.IGNORECASE)
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
payload = json.loads(body)
self.process_push(payload)
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
except Exception as e:
print(f"Error: {e}")
self.send_response(500)
self.end_headers()
self.wfile.write(str(e).encode())
def process_push(self, payload):
repo_name = payload.get("repository", {}).get("full_name", "unknown")
commits = payload.get("commits", [])
for commit in commits:
message = commit.get("message", "")
sha = commit.get("id", "")[:8]
author = commit.get("author", {}).get("name", "Unknown")
url = commit.get("url", "")
# Find all WP-XXX references
matches = WP_PATTERN.findall(message)
for wp_id in matches:
self.add_openproject_comment(
wp_id=wp_id,
repo=repo_name,
sha=sha,
message=message,
author=author,
url=url
)
def add_openproject_comment(self, wp_id, repo, sha, message, author, url):
if not OPENPROJECT_API_KEY:
print(f"No API key - would comment on WP-{wp_id}")
return
comment = f"""**Git Commit** [{sha}]({url})
**Repository:** {repo}
**Author:** {author}
```
{message}
```
"""
api_url = f"{OPENPROJECT_URL}/api/v3/work_packages/{wp_id}/activities"
data = json.dumps({"comment": {"raw": comment}}).encode()
# Basic auth with apikey as username
auth = base64.b64encode(f"apikey:{OPENPROJECT_API_KEY}".encode()).decode()
req = urllib.request.Request(
api_url,
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Basic {auth}"
},
method="POST"
)
try:
with urllib.request.urlopen(req) as resp:
print(f"Added comment to WP-{wp_id}: {resp.status}")
except Exception as e:
print(f"Failed to comment on WP-{wp_id}: {e}")
def log_message(self, format, *args):
print(f"[Webhook] {args[0]}")
if __name__ == "__main__":
port = int(os.getenv("PORT", 9000))
print(f"Starting webhook handler on port {port}")
HTTPServer(("0.0.0.0", port), WebhookHandler).serve_forever()