Initial commit: all 6 self-hosted app compose files + README

This commit is contained in:
Manohar Gupta 2026-04-26 23:08:58 +05:30
commit fa2a7e2282
7 changed files with 465 additions and 0 deletions

30
README.md Normal file
View file

@ -0,0 +1,30 @@
# Manohar's Infrastructure
Self-hosted stack on Hetzner CX32 (Helsinki), deployed via Dokploy + Traefik.
## Services
AppURLPurposeForgejo<https://git.manohargupta.com>Self-hosted Gitn8n<https://automate.manohargupta.com>Workflow automationApprise<https://notify.manohargupta.com>Notification API (Tailscale only)Miniflux<https://feeds.manohargupta.com>RSS readerChangeDetection<https://watch.manohargupta.com>Webpage change monitorPaperless-ngx<https://docs.manohargupta.com>Document OCR + searchTiger Agent<https://agent.manohargupta.com>AI orchestrationDokploy<https://dokploy.manohargupta.com>Docker orchestrationUptime Kuma<https://status.manohargupta.com>MonitoringUmami<https://analytics.manohargupta.com>Web analytics
## Stack
- **Server**: Hetzner CX32, Ubuntu 24.04, Helsinki (hel1)
- **Orchestration**: Dokploy (Docker Swarm)
- **Reverse proxy**: Traefik with Let's Encrypt TLS
- **Access**: Tailscale SSH only (`ssh root@manohar-ubuntu`)
- **DNS**: Namecheap → [manohargupta.com](http://manohargupta.com)
## Deployment notes
- All compose files are in `deployments/`
- Every compose declares `dokploy-network` as `external: true`
- Labels duplicated under both `labels:` and `deploy.labels:` — required because Dokploy uses swarm stack deploy for multi-service composes (swarm provider reads deploy.labels; docker provider reads labels)
- Secrets set in Dokploy Env tab, never hardcoded (except bcrypt hashes in labels which Dokploy cannot substitute)
- Apprise and ChangeDetection restricted to Tailscale CGNAT range (100.64.0.0/10)
## Key learnings
- Forgejo Docker image uses /data as root, not /var/lib/gitea
- n8n WEBHOOK_URL must match public domain exactly or webhook URLs are wrong
- htpasswd hashes in Traefik labels need $$ escaping (Compose interpolates single $)
- Dokploy env var substitution works in environment: blocks but NOT in labels:

52
apprise.compose.yml Normal file
View file

@ -0,0 +1,52 @@
# Apprise — notify.manohargupta.com
# Unified notification API. POST one message, fans out to Telegram, email, ~80 services.
# Stateless except for YAML config files stored in the config volume.
# No built-in auth -- protected by Traefik IP whitelist (Tailscale range only).
#
# Honest framing: n8n's built-in Telegram/email nodes cover most workflow notifications.
# Apprise's value is for non-n8n scripts (Python crons, server healthchecks) that also
# need to notify without importing the full n8n stack.
services:
apprise:
image: caronc/apprise:latest
restart: unless-stopped
environment:
# Persist notification configs (tagged YAML files) across restarts
APPRISE_STATEFUL_MODE: simple
# Allow API to update configs -- lock this down after initial setup
APPRISE_CONFIG_LOCK: "no"
volumes:
- apprise_config:/config # stores per-tag YAML notification configs
- apprise_attach:/attach # stores file attachments for notifications
networks:
- dokploy-network
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.apprise.rule=Host(`notify.manohargupta.com`)"
- "traefik.http.routers.apprise.entrypoints=websecure"
- "traefik.http.routers.apprise.tls.certresolver=letsencrypt"
- "traefik.http.services.apprise.loadbalancer.server.port=8000"
# IP whitelist -- Tailscale CGNAT range only. Only your tailnet can call this API.
# Without this, anyone who finds the URL can send notifications on your behalf.
- "traefik.http.middlewares.apprise-ipallow.ipwhitelist.sourcerange=100.64.0.0/10"
- "traefik.http.routers.apprise.middlewares=apprise-ipallow@docker"
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.apprise.rule=Host(`notify.manohargupta.com`)"
- "traefik.http.routers.apprise.entrypoints=websecure"
- "traefik.http.routers.apprise.tls.certresolver=letsencrypt"
- "traefik.http.services.apprise.loadbalancer.server.port=8000"
- "traefik.http.middlewares.apprise-ipallow.ipwhitelist.sourcerange=100.64.0.0/10"
- "traefik.http.routers.apprise.middlewares=apprise-ipallow@docker"
volumes:
apprise_config:
apprise_attach:
networks:
dokploy-network:
external: true

View file

@ -0,0 +1,61 @@
# ChangeDetection.io — watch.manohargupta.com
# Watches pages with no RSS: CERC orders, MNRE notifications, SECI tenders.
# Browserless sidecar handles JS-rendered government portals (Angular/React).
# Auth: bcrypt basic auth via Traefik (hash hardcoded -- env vars don't work in labels).
# Memory budget: ~700 MB total (CD ~150 MB + browser sidecar ~550 MB).
services:
browser:
image: dgtlmoon/sockpuppetbrowser:latest
restart: unless-stopped
cap_add:
- SYS_ADMIN
networks:
- cd_internal
environment:
- SCREEN_WIDTH=1920
- SCREEN_HEIGHT=1080
changedetection:
image: ghcr.io/dgtlmoon/changedetection.io:latest
restart: unless-stopped
depends_on:
- browser
environment:
PLAYWRIGHT_DRIVER_URL: ws://browser:3000
BASE_URL: https://watch.manohargupta.com
TZ: Asia/Kolkata
volumes:
- cd_data:/datastore
networks:
- dokploy-network
- cd_internal
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.changedetection.rule=Host(`watch.manohargupta.com`)"
- "traefik.http.routers.changedetection.entrypoints=websecure"
- "traefik.http.routers.changedetection.tls.certresolver=letsencrypt"
- "traefik.http.services.changedetection.loadbalancer.server.port=5000"
- "traefik.http.middlewares.cd-auth.basicauth.users=manohar:$$2y$$05$$KMo07kusNXiLmQ8orl6HseY5KyVof74gw6Z.7MSFtqYhuNX0otSJm"
- "traefik.http.routers.changedetection.middlewares=cd-auth@docker"
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.changedetection.rule=Host(`watch.manohargupta.com`)"
- "traefik.http.routers.changedetection.entrypoints=websecure"
- "traefik.http.routers.changedetection.tls.certresolver=letsencrypt"
- "traefik.http.services.changedetection.loadbalancer.server.port=5000"
- "traefik.http.middlewares.cd-auth.basicauth.users=manohar:$$2y$$05$$KMo07kusNXiLmQ8orl6HseY5KyVof74gw6Z.7MSFtqYhuNX0otSJm"
- "traefik.http.routers.changedetection.middlewares=cd-auth@docker"
volumes:
cd_data:
networks:
dokploy-network:
external: true
cd_internal:
driver: bridge

52
forgejo.compose.yml Normal file
View file

@ -0,0 +1,52 @@
# Forgejo — git.manohargupta.com
# Self-hosted Git for scripts, Power Query files, compose files, n8n exports.
# Storage: SQLite (single-user, zero ops overhead vs Postgres).
# SSH clone port: 2222 (host) -> 22 (container). Never conflicts with system SSH.
# Fix v2: volume mounted to /data (Forgejo Docker standard), not /var/lib/gitea.
services:
forgejo:
image: codeberg.org/forgejo/forgejo:10
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__server__DOMAIN: git.manohargupta.com
FORGEJO__server__ROOT_URL: https://git.manohargupta.com/
FORGEJO__server__SSH_DOMAIN: git.manohargupta.com
FORGEJO__server__SSH_PORT: "2222"
FORGEJO__server__SSH_LISTEN_PORT: "22"
FORGEJO__service__DISABLE_REGISTRATION: "true"
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
# /data is Forgejo Docker's standard root -- /var/lib/gitea was wrong
FORGEJO__database__DB_TYPE: sqlite3
FORGEJO__database__PATH: /data/gitea/forgejo.db
volumes:
- forgejo_data:/data # standard Forgejo Docker data root
- forgejo_config:/etc/gitea
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "2222:22"
networks:
- dokploy-network
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.forgejo.rule=Host(`git.manohargupta.com`)"
- "traefik.http.routers.forgejo.entrypoints=websecure"
- "traefik.http.routers.forgejo.tls.certresolver=letsencrypt"
- "traefik.http.services.forgejo.loadbalancer.server.port=3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
volumes:
forgejo_data:
forgejo_config:
networks:
dokploy-network:
external: true

68
miniflux.compose.yml Normal file
View file

@ -0,0 +1,68 @@
# Miniflux — feeds.manohargupta.com
# Minimal RSS reader. Postgres-only (no SQLite option in Miniflux).
# Intended sources: MercomIndia, IEEFA, MNRE press releases, CERC, SECI, etc.
# n8n will poll the Miniflux API to digest unread entries and push to Telegram.
services:
miniflux-db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: miniflux
POSTGRES_USER: miniflux
POSTGRES_PASSWORD: ${MINIFLUX_DB_PASSWORD}
volumes:
- miniflux_db_data:/var/lib/postgresql/data
networks:
- miniflux_internal # DB never touches the public network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U miniflux -d miniflux"]
interval: 10s
timeout: 5s
retries: 5
miniflux:
image: miniflux/miniflux:2.2.7 # Pinned -- upgrade deliberately
restart: unless-stopped
depends_on:
miniflux-db:
condition: service_healthy
environment:
# Full Postgres DSN -- sslmode=disable is fine on the internal bridge network
DATABASE_URL: postgres://miniflux:${MINIFLUX_DB_PASSWORD}@miniflux-db/miniflux?sslmode=disable
# Run DB migrations automatically on boot
RUN_MIGRATIONS: "1"
# Auto-create admin account on first boot -- these env vars are ignored after that
CREATE_ADMIN: "1"
ADMIN_USERNAME: ${MINIFLUX_ADMIN_USERNAME}
ADMIN_PASSWORD: ${MINIFLUX_ADMIN_PASSWORD}
# IST so relative timestamps ("2 hours ago") match your timezone
TZ: Asia/Kolkata
networks:
- dokploy-network
- miniflux_internal
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.miniflux.rule=Host(`feeds.manohargupta.com`)"
- "traefik.http.routers.miniflux.entrypoints=websecure"
- "traefik.http.routers.miniflux.tls.certresolver=letsencrypt"
- "traefik.http.services.miniflux.loadbalancer.server.port=8080"
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.miniflux.rule=Host(`feeds.manohargupta.com`)"
- "traefik.http.routers.miniflux.entrypoints=websecure"
- "traefik.http.routers.miniflux.tls.certresolver=letsencrypt"
- "traefik.http.services.miniflux.loadbalancer.server.port=8080"
volumes:
miniflux_db_data:
networks:
dokploy-network:
external: true
miniflux_internal:
driver: bridge

79
n8n.compose.yml Normal file
View file

@ -0,0 +1,79 @@
# n8n — automate.manohargupta.com
# Visual workflow automation. Postgres-backed (SQLite locks under concurrent runs).
# v2: Added deploy.labels for Traefik swarm provider (Dokploy uses swarm stack deploy).
services:
n8n-db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: n8n
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${N8N_DB_PASSWORD}
volumes:
- n8n_db_data:/var/lib/postgresql/data
networks:
- n8n_internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"]
interval: 10s
timeout: 5s
retries: 5
n8n:
image: docker.n8n.io/n8nio/n8n:1.83.0
restart: unless-stopped
depends_on:
n8n-db:
condition: service_healthy
environment:
N8N_HOST: automate.manohargupta.com
N8N_PORT: "5678"
N8N_PROTOCOL: https
WEBHOOK_URL: https://automate.manohargupta.com/
N8N_EDITOR_BASE_URL: https://automate.manohargupta.com/
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: n8n-db
DB_POSTGRESDB_PORT: "5432"
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${N8N_DB_PASSWORD}
# NEVER lose this key -- all stored credentials are encrypted with it
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
GENERIC_TIMEZONE: Asia/Kolkata
TZ: Asia/Kolkata
N8N_DIAGNOSTICS_ENABLED: "false"
N8N_VERSION_NOTIFICATIONS_ENABLED: "false"
volumes:
- n8n_data:/home/node/.n8n
networks:
- dokploy-network
- n8n_internal
# Container-level labels (docker provider)
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.n8n.rule=Host(`automate.manohargupta.com`)"
- "traefik.http.routers.n8n.entrypoints=websecure"
- "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
# Service-level labels (swarm provider) -- Dokploy deploys as swarm stack
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.n8n.rule=Host(`automate.manohargupta.com`)"
- "traefik.http.routers.n8n.entrypoints=websecure"
- "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
volumes:
n8n_data:
n8n_db_data:
networks:
dokploy-network:
external: true
n8n_internal:
driver: bridge

123
paperless.compose.yml Normal file
View file

@ -0,0 +1,123 @@
# Paperless-ngx — docs.manohargupta.com
# OCR + full-text search for PDFs and Office docs (lender drafts, tariff schedules, etc.)
# 5 containers: webserver, redis broker, postgres, tika (Office), gotenberg (PDF render).
# Tika + Gotenberg add ~400 MB RAM but are essential for .docx/.xlsx indexing.
# First boot is slow (~90s) -- DB migrations run before the web UI becomes available.
services:
# Redis: job queue between the web UI and the OCR/consumer worker
paperless-broker:
image: redis:7-alpine
restart: unless-stopped
volumes:
- paperless_redis:/data
networks:
- paperless_internal
# Postgres: document metadata, tags, correspondents, search index
paperless-db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: paperless
POSTGRES_USER: paperless
POSTGRES_PASSWORD: ${PAPERLESS_DB_PASSWORD}
volumes:
- paperless_db_data:/var/lib/postgresql/data
networks:
- paperless_internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperless -d paperless"]
interval: 10s
timeout: 5s
retries: 5
# Gotenberg: renders Office files (docx, xlsx) to PDF before OCR
paperless-gotenberg:
image: docker.io/gotenberg/gotenberg:8
restart: unless-stopped
command:
- "gotenberg"
- "--chromium-disable-javascript=true" # Security: no JS execution
- "--chromium-allow-list=file:///tmp/.*" # Only allow local file access
networks:
- paperless_internal
# Tika: extracts text from Office formats that Gotenberg can't handle alone
paperless-tika:
image: docker.io/apache/tika:latest
restart: unless-stopped
networks:
- paperless_internal
# Main app: web UI + OCR worker + consumer (watches the consume volume)
paperless:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
restart: unless-stopped
depends_on:
paperless-db:
condition: service_healthy
paperless-broker:
condition: service_started
paperless-gotenberg:
condition: service_started
paperless-tika:
condition: service_started
environment:
PAPERLESS_REDIS: redis://paperless-broker:6379
PAPERLESS_DBHOST: paperless-db
PAPERLESS_DBNAME: paperless
PAPERLESS_DBUSER: paperless
PAPERLESS_DBPASS: ${PAPERLESS_DB_PASSWORD}
# Secret key for Django session signing -- must be stable across restarts
PAPERLESS_SECRET_KEY: ${PAPERLESS_SECRET_KEY}
PAPERLESS_URL: https://docs.manohargupta.com
# Office doc support via Tika + Gotenberg
PAPERLESS_TIKA_ENABLED: "1"
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://paperless-gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://paperless-tika:9998
# OCR: 'skip' means don't re-OCR docs that already have a text layer (faster)
# Add '+hin' to language if you have Hindi documents: eng+hin (adds ~200 MB)
PAPERLESS_OCR_LANGUAGE: eng
PAPERLESS_OCR_MODE: skip
PAPERLESS_TIME_ZONE: Asia/Kolkata
USERMAP_UID: "1000"
USERMAP_GID: "1000"
volumes:
- paperless_data:/usr/src/paperless/data # search index, models
- paperless_media:/usr/src/paperless/media # original files + thumbnails
- paperless_export:/usr/src/paperless/export # manual export target
- paperless_consume:/usr/src/paperless/consume # drop files here to auto-ingest
networks:
- dokploy-network
- paperless_internal
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.paperless.rule=Host(`docs.manohargupta.com`)"
- "traefik.http.routers.paperless.entrypoints=websecure"
- "traefik.http.routers.paperless.tls.certresolver=letsencrypt"
- "traefik.http.services.paperless.loadbalancer.server.port=8000"
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.paperless.rule=Host(`docs.manohargupta.com`)"
- "traefik.http.routers.paperless.entrypoints=websecure"
- "traefik.http.routers.paperless.tls.certresolver=letsencrypt"
- "traefik.http.services.paperless.loadbalancer.server.port=8000"
volumes:
paperless_redis:
paperless_db_data:
paperless_data:
paperless_media:
paperless_export:
paperless_consume:
networks:
dokploy-network:
external: true
paperless_internal:
driver: bridge