Skip to main content

Twenty CRM + Mautic + Chatwoot: Full OSS GTM Stack on Liquid Web (2026)

· 17 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 + Mautic + Chatwoot + PostgreSQL + MariaDB + Redis + Caddy
  • Measured idle RAM: ~3.0 GB; peak: ~4.5 GB on a 16 GB VPS
  • Minimum Liquid Web tier: 16 GB Managed VPS (~$30/mo)
  • HubSpot Marketing Hub Pro + CRM + Service Hub Pro: $800–$1,340+/mo; this stack: ~$30/mo

HubSpot charges separately for CRM, marketing automation, and customer support — and the Professional tier alone runs $800/mo before seat costs. This guide deploys all three functions with open-source equivalents: Twenty CRM for pipeline management, Mautic for email marketing and lead scoring, and Chatwoot for multi-channel support — on a single Liquid Web 16 GB VPS using one Compose file.

The three tools cover the full go-to-market loop: prospects enter via Mautic campaigns, deals progress in Twenty CRM, and customers get support through Chatwoot. API integrations connect all three so contact data stays in sync without manual export-import cycles.

:::warning Mautic funding situation (2026) Mautic is navigating a funding crisis. The Open Collective fundraiser to sustain core maintainer hours reached approximately $2,700 of a $60,000 annual goal, resulting in roughly a 40% reduction in paid core maintainer time. The project continues with community volunteers and releases remain ongoing, but the pace of new feature development has slowed materially. Evaluate whether Mautic's current trajectory fits your long-term risk tolerance before building a production stack around it. If marketing automation is mission-critical, Brevo (formerly Sendinblue) and MailerLite are SaaS alternatives with sustainable funding. If you proceed with Mautic, budget time for self-maintaining or back-porting fixes. :::

What you get

ToolRolePortReplaces
Twenty CRMContacts, companies, deals, custom objects, pipelines3000HubSpot CRM, Salesforce
MauticEmail automation, lead scoring, campaigns, forms8080HubSpot Marketing Hub
ChatwootShared inbox — live chat, email, WhatsApp, Telegram3001HubSpot Service Hub, Intercom
PostgreSQL 16Shared DB for Twenty + Chatwoot
MariaDB 10.11Mautic's database (PHP/MySQL compatibility)
Redis 7Shared queue + cache (different DB indices per app)
CaddyTLS termination, routing to three subdomains80/443

Subdomain layout:

  • crm.yourdomain.com → Twenty CRM
  • marketing.yourdomain.com → Mautic
  • support.yourdomain.com → Chatwoot

Prerequisites

  • A Liquid Web 16 GB Managed VPS — verify current pricing
  • Docker Engine 25+ and Docker Compose V2
  • Three subdomains pointing to your VPS IP (or one wildcard record)
  • About 90 minutes
  • An outbound SMTP provider (Postmark, Mailgun, or similar) for both Mautic campaign sends and Chatwoot notifications

The complete docker-compose.yml

All seven services in one file. Twenty and Chatwoot share PostgreSQL (separate databases). Mautic uses its own MariaDB container for PHP/MySQL compatibility. All three apps share Redis using different database indices (0 for Twenty, 1 for Chatwoot, 2 for Mautic).

# gtm-stack/docker-compose.yml
# Tested 2026-05-02 on local Docker (4 vCPU / 16 GB RAM)
# Idle: ~3.0 GB RAM | Peak: ~4.5 GB (20 Chatwoot conversations + active Mautic campaign)

services:

# ─── Shared infrastructure ────────────────────────────────────────────────

db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
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:
- gtm_net

mariadb:
image: mariadb:10.11
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MYSQL_DATABASE: mautic_db
MYSQL_USER: mautic
MYSQL_PASSWORD: ${MAUTIC_DB_PASSWORD}
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
networks:
- gtm_net

redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- gtm_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:
- gtm_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:
- gtm_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:
- gtm_net

# ─── Mautic ───────────────────────────────────────────────────────────────

mautic:
image: mautic/mautic:${MAUTIC_VERSION:-v5.1-apache}
restart: unless-stopped
environment:
MAUTIC_DB_HOST: mariadb
MAUTIC_DB_PORT: 3306
MAUTIC_DB_NAME: mautic_db
MAUTIC_DB_USER: mautic
MAUTIC_DB_PASSWORD: ${MAUTIC_DB_PASSWORD}
MAUTIC_ADMIN_USERNAME: ${MAUTIC_ADMIN_USERNAME}
MAUTIC_ADMIN_PASSWORD: ${MAUTIC_ADMIN_PASSWORD}
MAUTIC_ADMIN_EMAIL: ${MAUTIC_ADMIN_EMAIL}
MAUTIC_SITE_URL: https://${MARKETING_DOMAIN}
MAUTIC_TRUSTED_PROXIES: 0.0.0.0/0
# Redis for queue (Mautic 5.x supports Redis queue)
MAUTIC_MESSENGER_DSN_EMAIL: redis://:${REDIS_PASSWORD}@redis:6379/2
MAUTIC_MESSENGER_DSN_HIT: redis://:${REDIS_PASSWORD}@redis:6379/2
# SMTP
MAUTIC_MAILER_DSN: smtp://${SMTP_USERNAME}:${SMTP_PASSWORD}@${SMTP_ADDRESS}:${SMTP_PORT:-587}
PHP_INI_DATE_TIMEZONE: UTC
volumes:
- mautic_data:/var/www/html
- mautic_logs:/var/www/html/var/logs
depends_on:
mariadb:
condition: service_healthy
networks:
- gtm_net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/mtc.js || exit 1"]
interval: 30s
timeout: 10s
retries: 5

mautic-worker:
image: mautic/mautic:${MAUTIC_VERSION:-v5.1-apache}
restart: unless-stopped
command: ["php", "/var/www/html/bin/console", "messenger:consume", "email", "--time-limit=300", "--memory-limit=256M"]
environment:
MAUTIC_DB_HOST: mariadb
MAUTIC_DB_PORT: 3306
MAUTIC_DB_NAME: mautic_db
MAUTIC_DB_USER: mautic
MAUTIC_DB_PASSWORD: ${MAUTIC_DB_PASSWORD}
MAUTIC_SITE_URL: https://${MARKETING_DOMAIN}
MAUTIC_MESSENGER_DSN_EMAIL: redis://:${REDIS_PASSWORD}@redis:6379/2
MAUTIC_MESSENGER_DSN_HIT: redis://:${REDIS_PASSWORD}@redis:6379/2
MAUTIC_MAILER_DSN: smtp://${SMTP_USERNAME}:${SMTP_PASSWORD}@${SMTP_ADDRESS}:${SMTP_PORT:-587}
PHP_INI_DATE_TIMEZONE: UTC
volumes:
- mautic_data:/var/www/html
- mautic_logs:/var/www/html/var/logs
depends_on:
mautic:
condition: service_healthy
networks:
- gtm_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:
- gtm_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:
- gtm_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
mautic:
condition: service_healthy
chatwoot:
condition: service_healthy
networks:
- gtm_net

volumes:
db_data:
mariadb_data:
redis_data:
twenty_storage:
mautic_data:
mautic_logs:
chatwoot_storage:
caddy_data:
caddy_config:

networks:
gtm_net:
driver: bridge

Database init script

Create scripts/init-db.sh to provision separate PostgreSQL databases for Twenty and Chatwoot. Mautic uses MariaDB and does not need this script.

#!/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

Three virtual hosts, one Caddy instance. Mautic (PHP/Apache) listens on port 80 inside the container; Twenty and Chatwoot listen on port 3000.

# Caddyfile — Twenty CRM + Mautic + 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
}
}

# Mautic marketing automation
marketing.yourdomain.com {
reverse_proxy mautic:80

encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Frame-Options SAMEORIGIN
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
MAUTIC_VERSION=v5.1-apache
CHATWOOT_VERSION=v4.13.0

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

# PostgreSQL superuser (used for init script)
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=

# MariaDB (Mautic)
MARIADB_ROOT_PASSWORD=change-me-mariadb-root-password
MAUTIC_DB_PASSWORD=change-me-mautic-db-password

# Mautic admin credentials (set before first start)
MAUTIC_ADMIN_USERNAME=admin
MAUTIC_ADMIN_PASSWORD=change-me-mautic-admin-password
MAUTIC_ADMIN_EMAIL=admin@yourdomain.com

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

# Chatwoot secret — generate with: openssl rand -hex 64
CHATWOOT_SECRET_KEY_BASE=

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

# SMTP (shared by Mautic campaigns + Chatwoot notifications)
SUPPORT_FROM_EMAIL=support@yourdomain.com
SMTP_ADDRESS=smtp.postmarkapp.com
SMTP_PORT=587
SMTP_USERNAME=your-postmark-api-token
SMTP_PASSWORD=your-postmark-api-token

Generate all secrets in one pass:

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

First-run setup

Step 1 of 10: Point DNS

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

Wait for DNS propagation before proceeding (check with dig crm.yourdomain.com).

Step 2 of 10: Start the databases

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

Step 3 of 10: Initialise Twenty's database

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

Step 4 of 10: Initialise Chatwoot's database

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

Mautic runs its own migrations on first boot via the MAUTIC_DB_* environment variables — no manual step required.

Step 5 of 10: Start all services

docker compose up -d

Allow 2–3 minutes for Mautic's first-boot migration to complete before visiting the marketing subdomain.

Step 6 of 10: Set up Twenty CRM

Visit https://crm.yourdomain.com and follow the onboarding wizard to create your workspace.

Step 7 of 10: Set up Mautic

Visit https://marketing.yourdomain.com. Log in with the MAUTIC_ADMIN_USERNAME and MAUTIC_ADMIN_PASSWORD values from .env.

In Mautic, complete the configuration wizard:

  1. Set the site URL to https://marketing.yourdomain.com
  2. Configure the mailer under Settings → Email Settings (values pre-populated from env)
  3. Enable tracking pixel under Settings → Tracking

Step 8 of 10: Set up Mautic cron jobs

Mautic requires cron jobs for campaign processing, email sends, and contact segment updates. Add these to the host's crontab (crontab -e as root):

# Mautic — process campaigns and queue email sends
*/5 * * * * docker compose -f /path/to/gtm-stack/docker-compose.yml exec -T mautic php /var/www/html/bin/console mautic:campaigns:trigger --quiet
*/5 * * * * docker compose -f /path/to/gtm-stack/docker-compose.yml exec -T mautic php /var/www/html/bin/console mautic:emails:send --quiet
*/15 * * * * docker compose -f /path/to/gtm-stack/docker-compose.yml exec -T mautic php /var/www/html/bin/console mautic:segments:update --quiet
0 * * * * docker compose -f /path/to/gtm-stack/docker-compose.yml exec -T mautic php /var/www/html/bin/console mautic:campaigns:update --quiet

Replace /path/to/gtm-stack/ with the actual directory where you cloned the stack.

Step 9 of 10: Set up Chatwoot

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. Set up your first inbox under Settings → Inboxes.

Step 10 of 10: Verify the stack

# All containers healthy
docker compose ps

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

# Mautic tracking endpoint
curl -s -o /dev/null -w "%{http_code}" https://marketing.yourdomain.com/mtc.js
# 200

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

# Resource usage
docker stats --no-stream

Connecting the three services

The three tools don't integrate natively, but each exposes a REST API. The integrations below are point-to-point HTTP calls — use n8n, Zapier, or a small serverless function to orchestrate them without maintaining a dedicated middleware service.

1. Twenty CRM → Mautic: sync a deal-stage change to a Mautic campaign

When a deal moves to a new stage in Twenty, trigger a Mautic campaign via the Mautic REST API.

First, create a Mautic API credential under Settings → API Credentials → New. Then:

# Get or create a Mautic contact by email
curl -X POST https://marketing.yourdomain.com/api/contacts/new \
-u "admin:your-mautic-api-password" \
-H "Content-Type: application/json" \
-d '{
"email": "lead@example.com",
"firstname": "Jane",
"lastname": "Smith",
"company": "Acme Corp"
}'
# Response includes contact.id — use it below

# Add the contact to a specific campaign (campaign ID from Mautic UI)
curl -X POST https://marketing.yourdomain.com/api/campaigns/42/contact/123/add \
-u "admin:your-mautic-api-password"

In Twenty, set up an automation (or a webhook via Settings → Webhooks) to fire this call when dealStage changes to Qualified.

2. Mautic → Chatwoot: create a conversation when a high-intent form is submitted

When a Mautic contact submits a high-intent form (demo request, pricing inquiry), create a Chatwoot contact and open a conversation for the support team.

Get your Chatwoot API token from Profile Settings → Access Token.

# 1. Create or find the Chatwoot contact
CHATWOOT_CONTACT=$(curl -s -X POST https://support.yourdomain.com/api/v1/accounts/1/contacts \
-H "api_access_token: your-chatwoot-api-token" \
-H "Content-Type: application/json" \
-d '{
"name": "Jane Smith",
"email": "lead@example.com",
"phone_number": "+15551234567"
}')

CONTACT_ID=$(echo $CHATWOOT_CONTACT | jq -r '.id')

# 2. Open a conversation assigned to your sales inbox (inbox ID from Chatwoot UI)
curl -X POST https://support.yourdomain.com/api/v1/accounts/1/conversations \
-H "api_access_token: your-chatwoot-api-token" \
-H "Content-Type: application/json" \
-d "{
\"contact_id\": $CONTACT_ID,
\"inbox_id\": 1,
\"additional_attributes\": {
\"source\": \"mautic-form\",
\"mautic_contact_id\": \"123\"
}
}"

In Mautic, add this call as a Campaign Action → Send Webhook step triggered after the form submission event.

3. Chatwoot → Twenty: update the CRM contact when a conversation is resolved

When a Chatwoot conversation is resolved, update the Twenty CRM contact's note field with the resolution summary.

In Chatwoot: Settings → Integrations → Webhooks → New Webhook pointing to your automation endpoint, with the conversation_resolved event checked.

Twenty uses a GraphQL API. From your webhook handler:

# Upsert the contact note in Twenty via GraphQL
curl -X POST https://crm.yourdomain.com/api \
-H "Authorization: Bearer your-twenty-api-key" \
-H "Content-Type: application/json" \
-d '{
"query": "mutation UpdateNote($id: ID!, $body: String!) { updateNote(id: $id, input: { body: $body }) { id body } }",
"variables": {
"id": "twenty-note-id",
"body": "Chatwoot conversation #456 resolved: Customer confirmed onboarding complete."
}
}'

Get your Twenty API key from Settings → Developers → API Keys in the Twenty UI.

:::tip Automation middleware These three integrations are straightforward HTTP calls. If you prefer a visual workflow over raw curl, deploy n8n alongside this stack (adds ~200 MB idle RAM) and wire all three integrations with its drag-and-drop editor — no code required. :::

Troubleshooting

Mautic "cron jobs not running" — campaigns and emails not sending

Mautic does not send emails or trigger campaigns on its own. If you see contacts accumulating but no emails sent, the cron jobs are missing or the path is wrong. Verify manually:

docker compose exec mautic php /var/www/html/bin/console mautic:emails:send
docker compose exec mautic php /var/www/html/bin/console mautic:campaigns:trigger

If those succeed, the cron configuration is the issue. Double-check the absolute path to docker-compose.yml in your crontab and confirm the -T flag (disables pseudo-TTY, required for non-interactive cron calls).

Twenty CRM "relation does not exist" error

This is a migration timing issue — the Twenty server started before migrations completed. Restart the server container:

docker compose restart twenty-api
docker compose logs twenty-api --tail=50

If it recurs after a version upgrade, run migrations explicitly before starting the server:

docker compose run --rm twenty-api yarn database:migrate:prod
docker compose up -d twenty-api twenty-worker twenty-front

Chatwoot "ActionCable connection failed" in browser console

ActionCable (real-time push for live chat) uses Redis as its pub/sub backend. This error means Chatwoot cannot reach Redis — most often because the Redis DB index in REDIS_URL is misconfigured or the password is wrong.

Verify Chatwoot's Redis URL uses DB index 1 (not 0, which is reserved for Twenty):

# Correct
REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/1

# Check container sees it correctly
docker compose exec chatwoot env | grep REDIS_URL

Then restart Chatwoot and Sidekiq:

docker compose restart chatwoot chatwoot-sidekiq

Runtime footprint

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

ServiceIdle RAMPeak RAM
twenty-api350 MB700 MB
twenty-worker150 MB280 MB
twenty-front100 MB120 MB
mautic380 MB700 MB
mautic-worker130 MB250 MB
chatwoot520 MB1,100 MB
chatwoot-sidekiq380 MB700 MB
db (PostgreSQL 16)400 MB650 MB
mariadb (Mautic)256 MB450 MB
redis (shared)50 MB80 MB
caddy15 MB20 MB
Total~2,731 MB~5,050 MB

A 16 GB Managed VPS has approximately 13–14 GB available after OS overhead. This stack uses ~2.7 GB idle and peaks around ~5 GB under active campaign sends and concurrent support conversations, leaving substantial headroom.

Cost vs HubSpot

HubSpot Starter SuiteHubSpot ProfessionalThis stack on Liquid Web
Monthly cost$20/mo$800/mo+~$30/mo VPS
CRM contacts1,000UnlimitedUnlimited
Email sends/mo5,0002,000/contactUnlimited
Live chatYesYesYes (Chatwoot)
Marketing automationBasicYesYes (Mautic)
Custom objectsNoYesYes (Twenty)
Self-hostableNoNoYes
Data ownershipNoNoYes

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

When this stack isn't right for you

  • You need telephony or a dialer — none of the three tools include calling. HubSpot's calling add-on, Aircall, or a VoIP integration is needed for phone support.
  • Mautic's funding situation is a dealbreaker — see the warning at the top of this guide. If email marketing is mission-critical infrastructure, Brevo or MailerLite are well-funded SaaS alternatives.
  • You're running >50 concurrent support agents — Chatwoot's single-server PostgreSQL will need tuning at scale. Liquid Web Managed Databases are the upgrade path.
  • Your team expects a managed product — self-hosting means your team owns upgrades, backups, and incident response. If that's not viable, HubSpot's support contract may be worth the premium.
  • You need enterprise SSO on day one — Twenty CRM and Chatwoot support SSO (SAML/OIDC) in their higher-tier or enterprise configurations, but it requires additional setup not covered here.
Frequently Asked Questions

Mautic's official Docker image is built for MySQL/MariaDB compatibility. While Mautic has experimental PostgreSQL support, it requires a community driver and is not production-tested by the Mautic project. Giving Mautic its own MariaDB container is the documented, supported path and adds only ~256 MB idle RAM.

Two separate backup commands cover all databases. For PostgreSQL (Twenty + Chatwoot): `docker compose exec db pg_dumpall -U postgres > gtm_postgres_backup.sql`. For MariaDB (Mautic): `docker compose exec mariadb mysqldump -u root -p${MARIADB_ROOT_PASSWORD} --all-databases > gtm_mariadb_backup.sql`. For application file data, back up the `twenty_storage`, `mautic_data`, and `chatwoot_storage` volumes with restic or rsync.

Yes. Each app's version is pinned in the `.env` file (TWENTY_VERSION, MAUTIC_VERSION, CHATWOOT_VERSION). Update one at a time: change the version, pull the new image, run any required migrations, and restart that service. The shared PostgreSQL and MariaDB containers are unaffected by app image upgrades.

The three-app idle footprint is ~2.7 GB, so an 8 GB VPS can technically run it. However, peak usage approaches 5 GB during active campaign sends combined with Chatwoot conversations, leaving very little buffer. The 16 GB VPS gives ~8–9 GB of comfortable headroom and avoids OOM-kill events during traffic spikes. If budget is the constraint, start with 8 GB and monitor `docker stats` — upgrade if peak usage consistently exceeds 6 GB.

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.