Twenty CRM + Mautic + Chatwoot: Full OSS GTM Stack on Liquid Web (2026)
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
| Tool | Role | Port | Replaces |
|---|---|---|---|
| Twenty CRM | Contacts, companies, deals, custom objects, pipelines | 3000 | HubSpot CRM, Salesforce |
| Mautic | Email automation, lead scoring, campaigns, forms | 8080 | HubSpot Marketing Hub |
| Chatwoot | Shared inbox — live chat, email, WhatsApp, Telegram | 3001 | HubSpot Service Hub, Intercom |
| PostgreSQL 16 | Shared DB for Twenty + Chatwoot | — | — |
| MariaDB 10.11 | Mautic's database (PHP/MySQL compatibility) | — | — |
| Redis 7 | Shared queue + cache (different DB indices per app) | — | — |
| Caddy | TLS termination, routing to three subdomains | 80/443 | — |
Subdomain layout:
crm.yourdomain.com→ Twenty CRMmarketing.yourdomain.com→ Mauticsupport.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:
- Set the site URL to
https://marketing.yourdomain.com - Configure the mailer under Settings → Email Settings (values pre-populated from env)
- 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.
| Service | Idle RAM | Peak RAM |
|---|---|---|
| twenty-api | 350 MB | 700 MB |
| twenty-worker | 150 MB | 280 MB |
| twenty-front | 100 MB | 120 MB |
| mautic | 380 MB | 700 MB |
| mautic-worker | 130 MB | 250 MB |
| chatwoot | 520 MB | 1,100 MB |
| chatwoot-sidekiq | 380 MB | 700 MB |
| db (PostgreSQL 16) | 400 MB | 650 MB |
| mariadb (Mautic) | 256 MB | 450 MB |
| redis (shared) | 50 MB | 80 MB |
| caddy | 15 MB | 20 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 Suite | HubSpot Professional | This stack on Liquid Web | |
|---|---|---|---|
| Monthly cost | $20/mo | $800/mo+ | ~$30/mo VPS |
| CRM contacts | 1,000 | Unlimited | Unlimited |
| Email sends/mo | 5,000 | 2,000/contact | Unlimited |
| Live chat | Yes | Yes | Yes (Chatwoot) |
| Marketing automation | Basic | Yes | Yes (Mautic) |
| Custom objects | No | Yes | Yes (Twenty) |
| Self-hostable | No | No | Yes |
| Data ownership | No | No | Yes |
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.
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.

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.
