Linux host + memory
Set up: provision a Linux box or VM with sudo access and at least 16GB RAM for this stack. A local model needs additional RAM or VRAM.
Verifyuname -s && free -hChatbotX (open source) on your own hardware. Facebook Messenger, Instagram, Telegram, WhatsApp, webchat... running on a machine you own, with AI replies from a model you control. No per-contact pricing. No message caps.
This is the exact build, including every error hit along the way and the fix for each. Total time if you follow this guide: ~45 minutes. Total time discovering everything in this guide: one long evening. I am publishing it because I do not want you paying me every month for a system running on your machine.
The ethos
I built this with open-source software because your customer messages and your AI should not depend on me or on any single software company. Especially not the closed platforms that have dominated this space while charging exorbitant monthly fees. Follow the guide or hire me to install it. Either way, you own the server, domain, login details, data, and the steps to run it.
The install is a handoff, not a subscription. You may still pay your server, domain, model, or other providers. You do not pay me every month to keep using the system I set up.
00 / Readiness
Set up: provision a Linux box or VM with sudo access and at least 16GB RAM for this stack. A local model needs additional RAM or VRAM.
Verifyuname -s && free -hSet up: use Docker’s official distro instructions and install the Compose plugin.
Verifysudo docker run hello-world && docker compose versionSet up: confirm the installer URL in the current official installation guide, download and inspect the script, then run it and finish onboarding.
Installcurl -fsSLo coolify-install.sh https://cdn.coollabs.io/coolify/install.sh && less coolify-install.sh && sudo bash coolify-install.shVerify: the dashboard opens and the connected server reports healthy.
Set up: add the domain, install cloudflared, then create and run a locally managed tunnel.
cloudflared tunnel ingress validateThe connector should show connected before you add the three routes in Step 2.
Set up: install it through your host package manager. On Debian or Ubuntu:
sudo apt-get update && sudo apt-get install -y opensslopenssl versionSet up: enable your distro’s OpenSSH server and configure a key or working login before you depend on the MailHog tunnel.
Verify from another machinessh you@your-boxSet up: complete developer registration and create an app.
Verify: the app dashboard opens, an App ID and secret are available, and you have admin access to the Page or professional IG account you will connect.
Set up: install or provision the endpoint you control, select a model, and keep its key private.
Verify: send one real /v1/chat/completions request from the host or container network and receive a controlled response before Step 7.
01 / Signal map
Nine containers: builder (the web app), worker (background jobs), realtime (websockets), postgres (TimescaleDB + pgvector), redis, filesystem (RustFS, S3-compatible storage), filesystem-init (one-shot bucket creator), mailhog (catches all email; this matters, so keep reading), adminer (DB admin UI). Three of them need public hostnames through the tunnel; the rest stay internal.
Text equivalent: browser and messaging traffic crosses the Cloudflare Tunnel into builder, realtime, or filesystem. The worker and data services communicate only on the Compose network; MailHog and Adminer are reached through loopback-only host bindings.
02 / The complete build
Mark each step complete as you go. Your progress stays in this browser. The guide explains the working system because ownership should include knowing how it works.
The upstream compose publishes ports you don't need public and will collide with anything else on your box (Postgres 5432, Redis 6379, MinIO-style 9000/9001, Adminer 8080). Check what's taken:
sudo ss -ltnp | grep -E ':(1999|3123|8025|8081|9004|9005)\b' || echo "Chosen ports are free"
This build uses loopback-only host bindings for 3123 (builder), 1999 (realtime), 9004/9005 (RustFS), 8025 (MailHog), and 8081 (Adminer). Cloudflare Tunnel can reach those local ports without exposing them on the public network. Adjust to your host, but keep the 127.0.0.1 bindings.
Three published application routes on your tunnel:
| Hostname | Service |
|---|---|
chatbotx.yourdomain.com | http://localhost:3123 |
chatbotx-rt.yourdomain.com | http://localhost:1999 |
chatbotx-s3.yourdomain.com | http://localhost:9004 |
Realtime and storage need their own hostnames because browsers hit them directly (websocket + media assets).
chatbotx.yourdomain.com behind a temporary Cloudflare Access policy that allows only your email. Keep it protected while the seeded demo login exists. Do not make the builder hostname public until Step 5 confirms the demo password has been rotated and the fixed login no longer works.New Coolify resource → Docker Compose Empty → paste. This is the upstream compose with every fix already applied (each fix is explained in the Troubleshooting section):
services:
postgres:
networks:
- main
image: 'timescale/timescaledb-ha:pg18.4-ts2.27.2-all'
restart: unless-stopped
volumes:
- 'db-data:/home/postgres/pgdata'
environment:
- POSTGRES_DB=chatbotx
- POSTGRES_USER=chatbotx
- 'POSTGRES_PASSWORD=${POSTGRES_PASSWORD}'
healthcheck:
test:
- CMD-SHELL
- 'pg_isready -d chatbotx -U chatbotx'
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
adminer:
networks:
- main
image: 'adminer:5.4.2'
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- '127.0.0.1:8081:8080'
mailhog:
networks:
- main
image: 'mailhog/mailhog:v1.0.1'
restart: unless-stopped
ports:
- '127.0.0.1:8025:8025'
filesystem:
networks:
- main
image: 'rustfs/rustfs:1.0.0-beta.8'
restart: unless-stopped
command: 'server /data --console-address ":9001"'
ports:
- '127.0.0.1:9004:9000'
- '127.0.0.1:9005:9001'
environment:
RUSTFS_ACCESS_KEY: '${RUSTFS_ACCESS_KEY}'
RUSTFS_SECRET_KEY: '${RUSTFS_SECRET_KEY}'
volumes:
- 'filesystem-data:/data'
healthcheck:
test:
- CMD
- sh
- '-c'
- 'curl -f http://127.0.0.1:9000/health'
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
filesystem-init:
networks:
- main
image: 'rustfs/rc:v0.1.22'
depends_on:
filesystem:
condition: service_healthy
environment:
RUSTFS_ACCESS_KEY: '${RUSTFS_ACCESS_KEY}'
RUSTFS_SECRET_KEY: '${RUSTFS_SECRET_KEY}'
entrypoint: "/bin/sh -c \"rc alias set local http://filesystem:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY}; rc mb local/chatbotx --ignore-existing; touch /tmp/empty && rc cp /tmp/empty local/chatbotx/public/; rc anonymous set public local/chatbotx/public;\"\n"
redis:
networks:
- main
image: 'redis:8.8.0-alpine'
restart: unless-stopped
volumes:
- 'redis-data:/data'
healthcheck:
test:
- CMD
- redis-cli
- ping
interval: 10s
timeout: 5s
retries: 5
builder:
networks:
- main
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
filesystem:
condition: service_healthy
image: 'ghcr.io/chatbotxio/chatbotx-builder:main'
platform: linux/amd64
restart: unless-stopped
environment:
BETTER_AUTH_SECRET: '${BETTER_AUTH_SECRET}'
BETTER_AUTH_URL: '${WEB_URL}'
DATABASE_URL: 'postgresql://chatbotx:${POSTGRES_PASSWORD}@postgres:5432/chatbotx?schema=public'
ENCRYPTION_KEY: '${ENCRYPTION_KEY}'
PLATFORM_ADMIN_EMAIL: '${PLATFORM_ADMIN_EMAIL}'
REALTIME_BROADCAST_SECRET: '${REALTIME_BROADCAST_SECRET}'
RUN_DB_MIGRATE: 'true'
RUN_DB_SEED: 'false'
SMTP_FROM: no-reply@yourdomain.com
NEXT_PUBLIC_ASSET_URL: '${ASSET_URL}'
NEXT_PUBLIC_BUILDER_URL: '${WEB_URL}'
NEXT_PUBLIC_EDITION: community
NEXT_PUBLIC_ENVIRONMENT: dev
NEXT_PUBLIC_SMTP_FROM: no-reply@yourdomain.com
NEXT_PUBLIC_REALTIME_URL: '${REALTIME_URL}'
NODE_OPTIONS: '--max_old_space_size=2048'
REALTIME_AUTH_URL: 'http://builder:3000'
REALTIME_API_KEY: '${REALTIME_API_KEY}'
REDIS_URL: 'redis://redis:6379'
S3_ACCESS_KEY_ID: '${RUSTFS_ACCESS_KEY}'
S3_BUCKET: chatbotx
S3_ENPOINT: 'http://filesystem:9000'
S3_REGION: us-east-1
S3_SECRET_ACCESS_KEY: '${RUSTFS_SECRET_KEY}'
SCHEDULER_BUCKET_RANGE: 0-255
SMTP_SERVER: 'smtp://username:password@mailhog:1025'
ports:
- '127.0.0.1:3123:3000'
healthcheck:
test:
- CMD
- curl
- '-f'
- 'http://localhost:3000/api/health'
interval: 30s
timeout: 10s
retries: 3
worker:
networks:
- main
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
filesystem:
condition: service_healthy
image: 'ghcr.io/chatbotxio/chatbotx-worker:main'
platform: linux/amd64
restart: unless-stopped
command:
- worker
- all
environment:
BETTER_AUTH_SECRET: '${BETTER_AUTH_SECRET}'
BETTER_AUTH_URL: '${WEB_URL}'
DATABASE_URL: 'postgresql://chatbotx:${POSTGRES_PASSWORD}@postgres:5432/chatbotx?schema=public'
ENCRYPTION_KEY: '${ENCRYPTION_KEY}'
PLATFORM_ADMIN_EMAIL: '${PLATFORM_ADMIN_EMAIL}'
REALTIME_BROADCAST_SECRET: '${REALTIME_BROADCAST_SECRET}'
RUN_DB_MIGRATE: 'false'
RUN_DB_SEED: 'false'
SMTP_FROM: no-reply@yourdomain.com
NEXT_PUBLIC_ASSET_URL: '${ASSET_URL}'
NEXT_PUBLIC_BUILDER_URL: '${WEB_URL}'
NEXT_PUBLIC_EDITION: community
NEXT_PUBLIC_ENVIRONMENT: dev
NEXT_PUBLIC_SMTP_FROM: no-reply@yourdomain.com
NEXT_PUBLIC_REALTIME_URL: '${REALTIME_URL}'
NODE_OPTIONS: '--max_old_space_size=2048'
REALTIME_AUTH_URL: 'http://builder:3000'
REALTIME_API_KEY: '${REALTIME_API_KEY}'
REDIS_URL: 'redis://redis:6379'
S3_ACCESS_KEY_ID: '${RUSTFS_ACCESS_KEY}'
S3_BUCKET: chatbotx
S3_ENPOINT: 'http://filesystem:9000'
S3_REGION: us-east-1
S3_SECRET_ACCESS_KEY: '${RUSTFS_SECRET_KEY}'
SCHEDULER_BUCKET_RANGE: 0-255
SMTP_SERVER: 'smtp://username:password@mailhog:1025'
realtime:
networks:
- main
image: 'ghcr.io/chatbotxio/chatbotx-realtime:main'
platform: linux/amd64
restart: unless-stopped
entrypoint: "bash -c \"cd /app/apps/realtime; echo \\\"REALTIME_BROADCAST_SECRET=$${REALTIME_BROADCAST_SECRET}\\\" > .env; HOSTNAME=0.0.0.0 PORT=1999 pnpm dlx partykit dev;\"\n"
environment:
REALTIME_BROADCAST_SECRET: '${REALTIME_BROADCAST_SECRET}'
NEXT_PUBLIC_BUILDER_URL: '${WEB_URL}'
NEXT_PUBLIC_REALTIME_URL: '${REALTIME_URL}'
REALTIME_AUTH_URL: 'http://builder:3000'
REALTIME_API_KEY: '${REALTIME_API_KEY}'
ports:
- '127.0.0.1:1999:1999'
networks:
main:
driver: bridge
volumes:
db-data: null
redis-data: null
filesystem-data: null
First deploy only: set RUN_DB_SEED: 'true' on the builder service. The seed creates the DEMO workspace and the default login. Flip it back to 'false' after first successful boot.
:main application tags are mutable; for repeatable production rebuilds, record the tested image digests. On an ARM host, platform: linux/amd64 may use emulation. Keep S3_ENPOINT exactly as supplied unless ChatbotX upstream confirms a different variable name.Coolify → Environment Variables tab. Generate secrets on the box:
openssl rand -hex 24 # POSTGRES_PASSWORD
openssl rand -hex 32 # ENCRYPTION_KEY
openssl rand -hex 32 # REALTIME_BROADCAST_SECRET
openssl rand -base64 32 # BETTER_AUTH_SECRET
openssl rand -base64 32 # REALTIME_API_KEY
openssl rand -base64 32 # RUSTFS_SECRET_KEY
| Variable | Value |
|---|---|
WEB_URL | https://chatbotx.yourdomain.com |
REALTIME_URL | https://chatbotx-rt.yourdomain.com |
ASSET_URL | https://chatbotx-s3.yourdomain.com/chatbotx/ |
PLATFORM_ADMIN_EMAIL | the email YOU will sign up with |
RUSTFS_ACCESS_KEY | chatbotx |
| everything else | generated above |
DATABASE_URL) must be hex, never base64. Base64 contains / and =, which break URL parsing, and the failure mode is a cryptic "Invalid environment variables" crash loop.Deploy. First boot pulls ~3GB of images and runs migrations. Give it a few minutes. filesystem-init will show perpetual "Restarting" in Coolify; that's cosmetic (it exits 0, Coolify relaunches it). Delete the service from the compose once uploads work.
While the builder hostname is still protected by your temporary Cloudflare Access policy, log in with the seeded account: demo@example.com / Demo@1234.
/auth/forgot-password → enter demo@example.com → the reset email lands in MailHog, not a real inbox. SSH tunnel to read it: ssh -L 8025:localhost:8025 you@your-box, then open http://localhost:8025. In a private browser, authenticate through Cloudflare Access first, then verify the original fixed password no longer works./auth/sign-up (verification email: MailHog again), then log back in as demo → workspace Settings → Admins → generate an invitation → open the invitation URL as your new account. Community edition grants full admin on invite.Then visit /admin as your account. This is the super-admin panel, unlocked when PLATFORM_ADMIN_EMAIL matches your email. Remove the temporary Cloudflare Access policy only after the original demo login fails, your own administrator works, and RUN_DB_SEED is back to 'false'.
/admin, the DEMO workspace is renamed, and RUN_DB_SEED is back to 'false'.One developer app at developers.facebook.com covers Messenger, Instagram, and WhatsApp. For pages and IG accounts you admin, no App Review is needed. Instagram requires the account be a Professional account (Creator or Business, which is a free switch in IG settings). Personal Facebook profiles cannot be automated at all; that's a Meta API limit, not a ChatbotX one.
/admin → Platform Credentials → enter your Meta App ID + Secret per platform. Set a webhook verify token using any random string, such as openssl rand -hex 16. Copy the webhook URL it shows you.feed (this is the field that carries comments). Don't panic if messages/messaging_postbacks aren't in the list. The newer dashboard hides messaging fields from this screen, and ChatbotX subscribes them programmatically when you connect each page (a Reconnect on the page in ChatbotX re-fires that call).messages, comments.feed landing on user webhooks instead of page webhooks (the dropdown makes that mistake easy, and the symptom is FB comments silently never arriving while IG works fine).Telegram needs none of this: grab a bot token from @BotFather and paste it. Webchat is built in.
Settings → AI → OpenAI Compatible provider → your endpoint URL, model name, key. Any OpenAI-compatible endpoint works: a local inference server, a gateway in front of your own hardware, whatever you run. This is the piece that separates self-hosted from the SaaS tools... the model reading your customers' messages is one you chose, on infrastructure you control.
The mechanic behind every "comment WORD and I'll DM you" post. This is a comment automation, not a flow. It is a settings form, not the visual builder. Direct routes (they're not top-level in the sidebar):
https://chatbotx.yourdomain.com/space/<workspaceId>/fb-comments
https://chatbotx.yourdomain.com/space/<workspaceId>/ig-comments
Build one per platform. The settings that matter:
build, BUILD, Build). Trailing punctuation matched fine in live testing (build! fired), but keep the CTA copy to one bare word regardless.parent_id with a different object prefix even for top-level comments, and ChatbotX's reply check compares the raw IDs. With the toggle on, every top-level comment on a photo post is falsely skipped as a "nested reply." The event arrives, nothing fires, no error anywhere.guide.yourdomain.com → wherever the deliverable lives), not the raw destination URL. The destination can move later without editing automations that already sent thousands of DMs.Testing methodology (each of these bit during the real build):
edited event, which automations ignore.docker logs $(docker ps -q --filter name=builder) 2>&1 | grep -iE 'comment|feed|webhook' | tail -20
Event present but no reply → ChatbotX config (keyword, targeting, the FB toggle above). No event → Meta side (wrong webhook object, feed not subscribed, page not reconnected).
127.0.0.1 (already done in the compose above). MailHog holds password-reset links for every account including yours, and ships with zero auth.ENCRYPTION_KEY.127.0.0.1. Verify that result with docker ps, keep router forwarding disabled, and confirm your provider firewall exposes none of those ports. Docker-published ports can bypass some host firewall rules, so the loopback binding is the primary control rather than a substitute for verification.03 / Final proof
Supplemental completion gate: one clean pass from container health through a real comment-to-DM reply.
04 / Error index
Search by symptom, error text, cause, or fix.
15 issues shown
| Symptom | Cause | Fix |
|---|---|---|
Crash loop: Invalid URL on DATABASE_URL | base64 password with / inside the connection string | generate a hex password, update POSTGRES_PASSWORD and DATABASE_URL together, then redeploy; preserve the existing volume |
Invalid environment variables: ENCRYPTION_KEY / SMTP_FROM / REALTIME_BROADCAST_SECRET | required vars missing from the docs compose | already in the compose above |
Postgres unhealthy: mkdir ... Permission denied | volume created by a previous image run, wrong ownership; Docker only chowns empty volumes | stop the stack, back up the volume, confirm the pinned image's expected UID/GID, repair ownership, and redeploy; recreate only a confirmed-disposable first-boot volume |
| 502 through Cloudflare | builder not up yet, or crash-looping | docker logs <builder> | head -40. The validator names the missing var at the top |
| Coolify shows a service perpetually "Restarting" | one-shot init container exits 0, Coolify relaunches it | cosmetic; remove filesystem-init after first run |
| Compose editor rejects your edit | Coolify inlines YAML anchors and rewrites the file on save; your x- blocks are dead after first save | edit the expanded per-service environment: blocks directly |
| Signup/reset email "never arrives" | it arrived in MailHog | ssh -L 8025:localhost:8025, open localhost:8025 |
| "Invalid redirect_uri" connecting Instagram | callback not whitelisted, or whitelisted on the wrong Meta product | Step 6.4: exact string, right product |
| Channel says "add settings to use this integration" | platform credentials not entered, or /admin locked | PLATFORM_ADMIN_EMAIL env + Platform Credentials |
| Works for you, ignores real customers | Meta app still in Development mode | flip to Live |
| IG comments fire, FB comments never arrive | feed subscribed on the User object instead of Page | Meta Activity log confirms; resubscribe feed under Page |
| FB comment event in logs, automation silent, no error | photo-post parent_id prefix quirk + "Don't reply to replies" falsely skipping top-level comments | turn that toggle OFF on the FB automation (keep it on for IG) |
| Keyword test "stops working" on retry | you edited the old comment; edited events are ignored | post a fresh comment each test |
| Automation fires for your tests, never for the page's own comments | comments from the page itself are suppressed by design | test from a separate account |
| Almost took a Live app down | App Mode header labels both toggle positions; filled toggle next to "Live" = already Live | if the dialog says "switch to development mode?", cancel |
No matching issue. Try a broader search or show every category.