From fa2a7e228295b65f290f63cff1a4f1a40f654acd Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 26 Apr 2026 23:08:58 +0530 Subject: [PATCH] Initial commit: all 6 self-hosted app compose files + README --- README.md | 30 +++++++++ apprise.compose.yml | 52 +++++++++++++++ changedetection.compose.yml | 61 ++++++++++++++++++ forgejo.compose.yml | 52 +++++++++++++++ miniflux.compose.yml | 68 ++++++++++++++++++++ n8n.compose.yml | 79 +++++++++++++++++++++++ paperless.compose.yml | 123 ++++++++++++++++++++++++++++++++++++ 7 files changed, 465 insertions(+) create mode 100644 README.md create mode 100644 apprise.compose.yml create mode 100644 changedetection.compose.yml create mode 100644 forgejo.compose.yml create mode 100644 miniflux.compose.yml create mode 100644 n8n.compose.yml create mode 100644 paperless.compose.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..e04aa08 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Manohar's Infrastructure + +Self-hosted stack on Hetzner CX32 (Helsinki), deployed via Dokploy + Traefik. + +## Services + +AppURLPurposeForgejoSelf-hosted Gitn8nWorkflow automationAppriseNotification API (Tailscale only)MinifluxRSS readerChangeDetectionWebpage change monitorPaperless-ngxDocument OCR + searchTiger AgentAI orchestrationDokployDocker orchestrationUptime KumaMonitoringUmamiWeb 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: diff --git a/apprise.compose.yml b/apprise.compose.yml new file mode 100644 index 0000000..911d4cb --- /dev/null +++ b/apprise.compose.yml @@ -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 diff --git a/changedetection.compose.yml b/changedetection.compose.yml new file mode 100644 index 0000000..8f700b4 --- /dev/null +++ b/changedetection.compose.yml @@ -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 diff --git a/forgejo.compose.yml b/forgejo.compose.yml new file mode 100644 index 0000000..abfff42 --- /dev/null +++ b/forgejo.compose.yml @@ -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 diff --git a/miniflux.compose.yml b/miniflux.compose.yml new file mode 100644 index 0000000..e60cfa4 --- /dev/null +++ b/miniflux.compose.yml @@ -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 diff --git a/n8n.compose.yml b/n8n.compose.yml new file mode 100644 index 0000000..b72e6fd --- /dev/null +++ b/n8n.compose.yml @@ -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 diff --git a/paperless.compose.yml b/paperless.compose.yml new file mode 100644 index 0000000..0a7a767 --- /dev/null +++ b/paperless.compose.yml @@ -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