Twenty CRM + Chatwoot on a Single VPS: The Open-Source HubSpot Alternative (2026)
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:
- Self-Host Twenty CRM — Twenty architecture, footprint, and standalone setup
- Self-Host Chatwoot — Chatwoot sustainability flag, architecture, and standalone setup
What you get
| Tool | Role | Replaces |
|---|---|---|
| Twenty CRM | Contacts, companies, deals, custom objects, pipelines | HubSpot CRM, Salesforce Essentials |
| Chatwoot | Shared inbox — live chat, email, WhatsApp, Telegram | Intercom, Zendesk Support |
| PostgreSQL 16 | Shared database (separate schemas per app) | — |
| Redis 7 | Shared queue + cache | — |
| Caddy | TLS 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.comandsupport.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.
| Service | Idle RAM | Peak RAM |
|---|---|---|
| twenty-api | 450 MB | 900 MB |
| twenty-worker | 200 MB | 350 MB |
| twenty-front | 80 MB | 100 MB |
| chatwoot | 520 MB | 1,100 MB |
| chatwoot-sidekiq | 380 MB | 700 MB |
| db (shared postgres) | 430 MB | 600 MB |
| redis (shared) | 20 MB | 35 MB |
| caddy | 15 MB | 20 MB |
| Total | 2,095 MB | 3,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) |
| CRM | ✓ | Twenty CRM ✓ |
| Live chat + inbox | ✓ | Chatwoot ✓ (+ WhatsApp, Telegram, and more) |
| Email marketing | ✓ HubSpot Marketing (separate) | Add Mautic ✓ |
| Phone / dialer | ✓ | Not 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:
- In Chatwoot: Settings → Integrations → Webhooks → New Webhook →
https://crm.yourdomain.com/api/webhooks/chatwoot - 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.
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.

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.
