Skip to content

Command Palette

Search for a command to run...

How to Deploy Twenty CRM on a VPS

Updated Aug 2026

Self-host Twenty on your own VPS with the official Docker Compose stack — a modern, open-source CRM and self-hosted alternative to Salesforce, up and running behind generated secrets in under an hour.

Before you start
  • A VPS with 2 vCPU / 4 GB RAM — Twenty's published minimum is 2 GB, but that number covers a single container; the real stack is server, worker, Postgres, and Redis running together
  • A fresh Ubuntu 24.04/26.04 server with root/sudo SSH access, Docker Engine + the Compose plugin installed (see Docker & Compose on Ubuntu)
  • A domain you can point at the server if you want real HTTPS rather than testing over a bare IP

What Twenty is

Twenty is an open-source CRM built around a modern, Notion-like interface — contacts, companies, deals, and fully customizable pipelines, with a GraphQL API and a Postgres-backed data model you can extend without writing code. It's positioned squarely as an open alternative to Salesforce: the same core object model (people, companies, opportunities) without the seat-based pricing or the vendor lock-in.

The appeal of self-hosting it is the usual one: your customer data lives on infrastructure you own, and the cost is the box, not a per-seat SaaS bill that grows with headcount. Twenty is under active development and licensed AGPL-3.0 — running an unmodified instance for your own team carries no extra obligations, but if you fork it and offer it as a hosted service to others, AGPL's source-disclosure terms kick in.

This guide uses Twenty's official Docker Compose stack straight from the twentyhq/twenty repository — the supported, documented install path — rather than a hand-rolled compose file.

Server sizing — why 4 GB, not 2

Twenty's published minimum is 2 GB of RAM, and it's easy to read that as "a 2 GB box is enough." It isn't, in practice, because Twenty isn't one process — the official compose stack brings up four services together: the server (the API and the web UI it serves), a worker that handles everything asynchronous — background jobs, workflow automation, imports, email sync — Postgres as the system of record, and Redis as the queue backend the worker reads from. Uploaded files and attachments land on local disk by default too, which needs headroom of its own as your data grows.

Four services sharing 2 GB leaves very little slack, and a Node-based server plus a Node-based worker are not light processes at idle, let alone under load. 2 vCPU / 4 GB RAM is the realistic floor for a small team's instance with room to breathe. That's why we point people at a Hetzner CX22 (2 vCPU / 4 GB) as the value pick, or Kamatera if you'd rather dial RAM precisely and grow into it. If your team is larger than a handful of people or you're importing a big existing contact/company database, size up to 8 GB — it's cheaper than fighting OOM kills on the worker mid-import.

Prepare the server

This guide assumes you've already worked through Docker & Compose on Ubuntu — a fresh Ubuntu 24.04 or 26.04 box, Docker Engine and the Compose plugin installed, and ufw allowing SSH plus 80 and 443. Twenty's server listens on port 3000 by default; you don't need to open it to the internet — the plan below is to proxy it through Caddy on 443, the same pattern as HTTPS for any other Docker app on this site.

Install Twenty

Twenty publishes an official docker-compose.yml and a matching .env.example in the twenty-docker package of its repo. Pull both down, then generate the two secrets the compose file needs and append them to .env:

mkdir twenty && cd twenty
wget -O docker-compose.yml https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/docker-compose.yml
wget -O .env https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/.env.example
echo "PG_DATABASE_PASSWORD=$(openssl rand -base64 24 | tr -d '\n')" >> .env
echo "ENCRYPTION_KEY=$(openssl rand -base64 32 | tr -d '\n')" >> .env

A couple of things worth understanding about those two values before you move on:

  • PG_DATABASE_PASSWORD is the password Twenty's Postgres container is initialized with, and the one the server and worker use to reach it. It only matters on first boot — change it later and the running database won't match it anymore.
  • ENCRYPTION_KEY is the key Twenty uses to encrypt sensitive data at rest (tokens, secrets stored in the app) and to sign sessions. Treat it like a root password: back up .env somewhere safe right now. Lose this key and encrypted data becomes unrecoverable; change it later on a running instance and existing sessions and encrypted values break.

.env.example ships more variables than just those two — most are fine at their defaults for a first deploy, but set SERVER_URL to the address you'll actually reach the app on before you go live (https://crm.example.com once you've wired up the domain below, or http://SERVER_IP:3000 if you're only testing). Twenty uses it to build links, API URLs, and outbound email content, and the default only works for local testing.

Bring the stack up:

docker compose up -d

Give it a minute — the server runs its database migrations against Postgres on first boot. Then confirm every container is healthy:

docker compose ps
docker compose logs -f server

You should see db, redis, server, and worker all Up. Once the server's log settles, it's reachable at http://SERVER_IP:3000.

HTTPS + domain

Testing over http://SERVER_IP:3000 is fine for a first look, but a CRM holding real customer data should be served over HTTPS on a real domain — plain HTTP means credentials and session cookies cross the network in the clear. Twenty's official compose file doesn't bundle a reverse proxy, so add one.

The simplest path is Automatic HTTPS with Caddy, which fetches and renews Let's Encrypt certificates for you with a couple of lines of config. Point an A record for your hostname (say crm.example.com) at the server's public IP, then have Caddy reverse-proxy that hostname to 127.0.0.1:3000. Once DNS resolves, Caddy issues a certificate automatically and Twenty is live on HTTPS.

After DNS and Caddy are both working, go back and set SERVER_URL in .env to your real https://crm.example.com address if you hadn't already, then restart so the server picks it up:

docker compose up -d

A mismatched SERVER_URL is the single most common misconfiguration on a first deploy — get it wrong and links in emails, the API base URL, and file download links all point at the wrong place.

First-run setup

Load https://crm.example.com (or http://SERVER_IP:3000 if you're still testing) in a browser. Twenty shows a sign-up screen on a fresh install.

Create your account and workspace immediately. As with most self-hosted tools, the sign-up screen is open to anyone who can reach it until the first account exists — whoever signs up first owns the workspace. Enter your email, set a strong password, and name your workspace.

Once you're in, do a quick smoke test to confirm the whole stack is actually wired together correctly:

  1. Create a Company record and a Person attached to it — this exercises the core data model and confirms writes are landing in Postgres.
  2. Open the Opportunities (or Deals) view and drag a card between pipeline stages — this is a good check that the UI and API round-trip cleanly.
  3. Upload a file to a record (an avatar or an attachment) and reload the page — this confirms the worker and local file storage are both working, not just the server.

If all three work, the server, worker, Postgres, and Redis are all doing their job.

Backups

Two things matter here, and losing either one hurts:

  • The Postgres data — every contact, company, deal, and pipeline you've built lives in the db service's volume. This is the actual content of your CRM.
  • .env — specifically ENCRYPTION_KEY. Restore a Postgres backup onto an instance running a different encryption key and anything Twenty encrypted at rest becomes unreadable, even though the raw rows are intact.

Dump the database and copy it, plus .env, off the box:

docker compose exec -T db pg_dump -U postgres default | gzip > twenty-db-$(date +%F).sql.gz

(Confirm the exact database/user names for your install with docker compose exec db env | grep PG_ if you changed anything from the .env.example defaults.) Also snapshot the storage volume that holds uploaded files and attachments — check docker compose config for the volume name if you're not sure which one it is.

Get all of it — the SQL dump, .env, and the storage volume — to an S3-compatible bucket or another machine on a schedule. A backup that only lives on the same server dies with the server, and an untested backup is a guess: periodically restore into a throwaway stack to confirm it actually works.

Upgrades

Twenty ships new releases regularly. To upgrade, pull newer images and recreate the containers:

docker compose pull
docker compose up -d

The server runs pending database migrations automatically on boot, which is exactly why a fresh backup before you pull is non-negotiable — a migration changes your data in place and there's no undo without one. Read the release notes on the Twenty GitHub releases page before a major version bump, and consider pinning specific image tags in docker-compose.yml instead of riding latest, so you control exactly when you move and can reproduce the running version if something breaks.

Troubleshooting

Server container restarts in a loop right after docker compose up -d. Usually it started before Postgres was ready to accept connections. Check docker compose logs db for startup errors first, then docker compose logs server — a docker compose up -d retry once Postgres settles is often enough.

The app loads but API calls fail, or links in the UI point at the wrong host. SERVER_URL doesn't match the address you're actually serving from. Set it to your real public HTTPS URL in .env and restart the stack — this is the same class of bug as a wrong WEBHOOK_URL in other self-hosted apps: the app has to know its own public address, it can't infer it from the request.

Uploaded files or avatars 404 after restarting the stack. The local storage volume isn't being persisted — confirm the compose file's storage volume mount survived any edits you made, and that you're not accidentally running with docker compose down -v, which deletes volumes.

Login sessions keep getting invalidated, or previously-working sessions break after a redeploy. ENCRYPTION_KEY changed between deploys. It must stay identical across restarts and upgrades — restore it from your backup if it drifted.

Certificate never issues / stuck on plain HTTP. This is a Caddy/DNS problem, not a Twenty one — confirm the A record for your domain has propagated (dig +short crm.example.com) and that port 80 is reachable from the internet for the ACME challenge to complete.

Verification + next steps

You're done when you can: load Twenty over HTTPS on your own domain, sign in as the account you created, create and move records through a pipeline, upload a file and see it persist across a restart, and confirm a database dump plus .env are landing in off-box storage.

From here, invite your team, import existing contacts and companies, and build out the pipeline stages that match how you actually sell. The box underneath it is what makes or breaks the experience once real usage starts — a Hetzner CX22 (2 vCPU / 4 GB) is the value pick to start, or reach for Kamatera when you want to dial RAM precisely as your team and data grow. See Best VPS for self-hosting for the ranked picks.

Next steps

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.