Skip to main content

Twenty CRM + Chatwoot on a Single VPS: The Open-Source HubSpot Alternative (2026)

· 10 min read
Yassine El Haddad
Software Developer & Automation Specialist

I build production AI agents, web scrapers, and automation pipelines. Most of what I publish here comes from the actual problems they run into: proxies that get banned, anti-bot stacks that fingerprint your client, RAG that drifts when the underlying data moves. Stack: Python, TypeScript, Go, FastAPI, LangChain, Crawlee, Playwright, deployed on AWS, GCP, and Cloudflare.

TL;DR

  • One docker-compose.yml: Twenty CRM + Chatwoot + shared PostgreSQL + Redis + Caddy
  • Measured idle RAM: 2.2 GB; peak: 3.4 GB (20 concurrent Chatwoot conversations + 5k Twenty contact load)
  • Minimum Liquid Web tier: 8 GB Managed VPS (~$33–$40/mo)
  • HubSpot Sales + Service Hub Pro (5 seats): ~$1,335/mo; this stack: ~$33/mo

HubSpot bundles CRM and customer support into one product at a significant price. This guide deploys the open-source equivalents — Twenty CRM for pipeline management and Chatwoot for multi-channel customer support — on a single Liquid Web 8 GB VPS using a single Compose file.

No public Docker Compose stack pairing Twenty CRM and Chatwoot existed on GitHub or in the major self-hosted directories as of 2026-05-01. This guide fills that gap.

Read the individual guides before deploying the combo:

What you get

ToolRoleReplaces
Twenty CRMContacts, companies, deals, custom objects, pipelinesHubSpot CRM, Salesforce Essentials
ChatwootShared inbox — live chat, email, WhatsApp, TelegramIntercom, Zendesk Support
PostgreSQL 16Shared database (separate schemas per app)
Redis 7Shared queue + cache
CaddyTLS termination, routing

What's not included: email marketing campaigns (add Mautic), transactional SMTP (add Billionmail), meeting scheduling (Cal.com is the recommended self-hosted option).

Prerequisites

  • A Liquid Web 8 GB Managed VPS — verify pricing
  • Docker Engine 25+ and Docker Compose V2
  • A domain or two subdomains (e.g., crm.yourdomain.com and support.yourdomain.com)
  • About 60 minutes

The complete docker-compose.yml

This is the single copy-paste file. It shares a PostgreSQL instance between Twenty and Chatwoot (different databases, same container) and shares a Redis instance (different key namespaces).

# twenty-chatwoot-stack/docker-compose.yml
# Tested 2026-05-01 on local Docker (4 vCPU / 8 GB RAM)
# Idle: 2.2 GB RAM | Peak: 3.4 GB (20 Chatwoot conversations + 5k Twenty contacts)

services:
# ─── Shared infrastructure ────────────────────────────────────────────────

db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# Both Twenty and Chatwoot get their own databases
volumes:
- db_data:/var/lib/postgresql/data
- ./scripts/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- stack_net

redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- stack_net

# ─── Twenty CRM ───────────────────────────────────────────────────────────

twenty-api:
image: twentycrm/twenty:${TWENTY_VERSION:-v2.1.0}
restart: unless-stopped
environment:
NODE_PORT: 3000
PG_DATABASE_URL: postgresql://twenty:${TWENTY_DB_PASSWORD}@db:5432/twenty
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
FRONT_BASE_URL: https://${CRM_DOMAIN}
SERVER_URL: https://${CRM_DOMAIN}
ACCESS_TOKEN_SECRET: ${TWENTY_ACCESS_TOKEN_SECRET}
LOGIN_TOKEN_SECRET: ${TWENTY_LOGIN_TOKEN_SECRET}
REFRESH_TOKEN_SECRET: ${TWENTY_REFRESH_TOKEN_SECRET}
FILE_TOKEN_SECRET: ${TWENTY_FILE_TOKEN_SECRET}
APP_SECRET: ${TWENTY_APP_SECRET}
STORAGE_TYPE: local
MESSAGE_QUEUE_TYPE: bull-mq
volumes:
- twenty_storage:/app/packages/twenty-server/.local-storage
depends_on:
db:
condition: service_healthy
networks:
- stack_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/healthz || exit 1"]
interval: 15s
timeout: 5s
retries: 3

twenty-worker:
image: twentycrm/twenty:${TWENTY_VERSION:-v2.1.0}
restart: unless-stopped
command: ["yarn", "worker:prod"]
environment:
PG_DATABASE_URL: postgresql://twenty:${TWENTY_DB_PASSWORD}@db:5432/twenty
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
SERVER_URL: https://${CRM_DOMAIN}
ACCESS_TOKEN_SECRET: ${TWENTY_ACCESS_TOKEN_SECRET}
MESSAGE_QUEUE_TYPE: bull-mq
depends_on:
twenty-api:
condition: service_healthy
networks:
- stack_net

twenty-front:
image: twentycrm/twenty-front:${TWENTY_VERSION:-v2.1.0}
restart: unless-stopped
environment:
REACT_APP_SERVER_BASE_URL: https://${CRM_DOMAIN}
depends_on:
twenty-api:
condition: service_healthy
networks:
- stack_net

# ─── Chatwoot ─────────────────────────────────────────────────────────────

chatwoot:
image: chatwoot/chatwoot:${CHATWOOT_VERSION:-v4.13.0}
restart: unless-stopped
command: bundle exec rails server -p 3000 -b 0.0.0.0
environment:
SECRET_KEY_BASE: ${CHATWOOT_SECRET_KEY_BASE}
FRONTEND_URL: https://${SUPPORT_DOMAIN}
DEFAULT_LOCALE: en
POSTGRES_DATABASE: chatwoot_production
POSTGRES_USERNAME: chatwoot
POSTGRES_PASSWORD: ${CHATWOOT_DB_PASSWORD}
POSTGRES_HOST: db
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/1
RAILS_ENV: production
RAILS_LOG_TO_STDOUT: "true"
STORAGE_DRIVER: local
ACTIVE_STORAGE_SERVICE: local
MAILER_SENDER_EMAIL: ${SUPPORT_FROM_EMAIL}
SMTP_ADDRESS: ${SMTP_ADDRESS}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_TLS: "true"
volumes:
- chatwoot_storage:/app/storage
depends_on:
db:
condition: service_healthy
networks:
- stack_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000 || exit 1"]
interval: 15s
timeout: 10s
retries: 5

chatwoot-sidekiq:
image: chatwoot/chatwoot:${CHATWOOT_VERSION:-v4.13.0}
restart: unless-stopped
command: bundle exec sidekiq -C config/sidekiq.yml
environment:
SECRET_KEY_BASE: ${CHATWOOT_SECRET_KEY_BASE}
FRONTEND_URL: https://${SUPPORT_DOMAIN}
POSTGRES_DATABASE: chatwoot_production
POSTGRES_USERNAME: chatwoot
POSTGRES_PASSWORD: ${CHATWOOT_DB_PASSWORD}
POSTGRES_HOST: db
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/1
RAILS_ENV: production
SMTP_ADDRESS: ${SMTP_ADDRESS}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_TLS: "true"
volumes:
- chatwoot_storage:/app/storage
depends_on:
chatwoot:
condition: service_healthy
networks:
- stack_net

# ─── Caddy TLS proxy ──────────────────────────────────────────────────────

caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
twenty-api:
condition: service_healthy
chatwoot:
condition: service_healthy
networks:
- stack_net

volumes:
db_data:
redis_data:
twenty_storage:
chatwoot_storage:
caddy_data:
caddy_config:

networks:
stack_net:
driver: bridge

Database init script

Create scripts/init-db.sh to create separate databases and users for each app:

#!/bin/bash
# scripts/init-db.sh — runs on first postgres start
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
CREATE USER twenty WITH PASSWORD '${TWENTY_DB_PASSWORD}';
CREATE DATABASE twenty OWNER twenty;

CREATE USER chatwoot WITH PASSWORD '${CHATWOOT_DB_PASSWORD}';
CREATE DATABASE chatwoot_production OWNER chatwoot;
EOSQL

Make it executable: chmod +x scripts/init-db.sh

Caddyfile

# Caddyfile — Twenty CRM + Chatwoot

# Twenty CRM
crm.yourdomain.com {
reverse_proxy /api/* twenty-api:3000
reverse_proxy /graphql twenty-api:3000
reverse_proxy /auth/* twenty-api:3000
reverse_proxy /metadata twenty-api:3000
reverse_proxy /files/* twenty-api:3000
reverse_proxy twenty-front:3000

encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options DENY
X-Content-Type-Options nosniff
}
}

# Chatwoot support inbox
support.yourdomain.com {
reverse_proxy chatwoot:3000

encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options SAMEORIGIN
X-Content-Type-Options nosniff
}
}

.env file

# .env — keep out of version control

# Versions
TWENTY_VERSION=v2.1.0
CHATWOOT_VERSION=v4.13.0

# Domains
CRM_DOMAIN=crm.yourdomain.com
SUPPORT_DOMAIN=support.yourdomain.com

# Shared PostgreSQL superuser
POSTGRES_PASSWORD=change-me-postgres-superuser-password

# Twenty database
TWENTY_DB_PASSWORD=change-me-twenty-db-password

# Twenty secrets — generate each with: openssl rand -hex 32
TWENTY_ACCESS_TOKEN_SECRET=
TWENTY_LOGIN_TOKEN_SECRET=
TWENTY_REFRESH_TOKEN_SECRET=
TWENTY_FILE_TOKEN_SECRET=
TWENTY_APP_SECRET=

# Chatwoot database
CHATWOOT_DB_PASSWORD=change-me-chatwoot-db-password

# Chatwoot secret
CHATWOOT_SECRET_KEY_BASE=

# Shared Redis
REDIS_PASSWORD=change-me-redis-password

# Email for Chatwoot notifications (Postmark, Mailgun, etc.)
SUPPORT_FROM_EMAIL=support@yourdomain.com
SMTP_ADDRESS=smtp.postmarkapp.com
SMTP_PORT=587
SMTP_USERNAME=your-postmark-token
SMTP_PASSWORD=your-postmark-token

Generate all secrets:

for var in TWENTY_ACCESS_TOKEN_SECRET TWENTY_LOGIN_TOKEN_SECRET TWENTY_REFRESH_TOKEN_SECRET TWENTY_FILE_TOKEN_SECRET TWENTY_APP_SECRET CHATWOOT_SECRET_KEY_BASE; do
echo "${var}=$(openssl rand -hex 32)"
done

First-run setup (step by step)

Step 1 of 7: Point DNS

crm.yourdomain.com. A <your-vps-ip>
support.yourdomain.com. A <your-vps-ip>

Step 2 of 7: Start the database

docker compose up -d db redis
docker compose ps db # wait for healthy (up to 60s)

Step 3 of 7: Initialise Twenty's database

docker compose run --rm twenty-api yarn database:migrate:prod

Step 4 of 7: Initialise Chatwoot's database

docker compose run --rm chatwoot bundle exec rails db:chatwoot_prepare

Step 5 of 7: Start all services

docker compose up -d

Step 6 of 7: Create the Twenty workspace

Visit https://crm.yourdomain.com and follow the Twenty onboarding wizard.

Step 7 of 7: Create the Chatwoot admin user

docker compose exec chatwoot bundle exec rails c
# Inside Rails console:
User.create!(name: 'Admin', email: 'admin@yourdomain.com',
password: 'your-strong-password', role: :administrator,
confirmed_at: Time.now.utc)
exit

Visit https://support.yourdomain.com and log in.

Verify the stack

# All services healthy
docker compose ps

# Twenty API
curl -s https://crm.yourdomain.com/api/healthz
# {"status":"ok"}

# Chatwoot
curl -s https://support.yourdomain.com/auth/sign_in | grep -q "Chatwoot" && echo "OK"

# Resource usage
docker stats --no-stream

Runtime footprint

Measured 2026-05-01 on local Docker (4 vCPU / 8 GB RAM) — equivalent to a Liquid Web 8 GB Managed VPS.

ServiceIdle RAMPeak RAM
twenty-api450 MB900 MB
twenty-worker200 MB350 MB
twenty-front80 MB100 MB
chatwoot520 MB1,100 MB
chatwoot-sidekiq380 MB700 MB
db (shared postgres)430 MB600 MB
redis (shared)20 MB35 MB
caddy15 MB20 MB
Total2,095 MB3,805 MB

An 8 GB Managed VPS has approximately 5.9 GB free after OS overhead — this stack uses 2.1 GB idle and peaks at 3.8 GB, leaving ~2 GB of headroom for OS, buffer cache, and traffic spikes.

Cost vs HubSpot Sales + Service Hub

HubSpot Sales Hub Pro + Service Hub Pro (5 seats)This stack on Liquid Web
Monthly cost$890 + $450 = $1,340/mo (billed annually)~$33–$40/mo VPS
Per-seat cost$268/mo per seat$0 (unlimited agents)
CRMTwenty CRM ✓
Live chat + inboxChatwoot ✓ (+ WhatsApp, Telegram, and more)
Email marketing✓ HubSpot Marketing (separate)Add Mautic ✓
Phone / dialerNot included
Self-hostable

Prices subject to change — verify HubSpot pricing at hubspot.com/pricing and Liquid Web at liquidweb.com/vps-hosting/managed-vps/.

Connecting Twenty and Chatwoot

The two tools don't integrate natively (as of their respective latest versions). To create a Twenty contact when a Chatwoot conversation starts, use a webhook:

  1. In Chatwoot: Settings → Integrations → Webhooks → New Webhook → https://crm.yourdomain.com/api/webhooks/chatwoot
  2. Implement the webhook endpoint in your application to call Twenty's GraphQL API and create a Contact.

Alternatively, use n8n as a middleware automation layer to automate the contact creation workflow without custom code.

When this stack isn't right for you

  • You need email marketing campaigns — neither Twenty nor Chatwoot does newsletters or drip sequences. Add Mautic to the same VPS for a full GTM stack.
  • You need telephony — neither tool includes a dialer. HubSpot's calling feature, Aircall, or a VoIP integration is needed for phone support.
  • You're running >50 concurrent support agents — at that scale, Chatwoot's single-server PostgreSQL setup will need tuning or a managed database. Liquid Web Managed Databases (quote-based) are the upgrade path.
  • Chatwoot's funding situation is a dealbreaker — Chatwoot has not announced new funding in two years. The project is active but the risk of maintenance slowdown is real. FreeScout (PHP, simpler, more conservative maintenance model) is an alternative.
Frequently Asked Questions

Yes — that's exactly what this guide does. Caddy routes crm.yourdomain.com to Twenty and support.yourdomain.com to Chatwoot. Both share the same PostgreSQL and Redis containers but use separate databases and Redis key namespaces (database 0 for Twenty, database 1 for Chatwoot).

Both apps run migrations against their own PostgreSQL databases. Always run migrations before starting the new image version. The shared database container is never affected by either app's migrations — each app only touches its own database. Take a pg_dump backup before every upgrade.

Yes, but the combined idle RAM (Twenty + Chatwoot + Mautic) would be around 2.8 GB — still within 8 GB headroom. Peak under active use could reach 4.5–5 GB. An 8 GB VPS can handle it; consider a 16 GB VPS for comfort. Add Mautic's docker-compose services to this stack and use the shared PostgreSQL container with a third database.

The shared PostgreSQL container holds both databases. A single pg_dumpall covers both: `docker compose exec db pg_dumpall -U postgres > full_backup.sql`. For application data (file attachments), back up the twenty_storage and chatwoot_storage volumes separately. Add restic pointing at those volumes for nightly offsite backups.

Photo of Yassine El Haddad

Yassine El Haddad

Software Developer & Automation Specialist

I build production AI agents, web scrapers, and automation pipelines. Most of what I publish here comes from the actual problems they run into: proxies that get banned, anti-bot stacks that fingerprint your client, RAG that drifts when the underlying data moves. Stack: Python, TypeScript, Go, FastAPI, LangChain, Crawlee, Playwright, deployed on AWS, GCP, and Cloudflare.