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.
- 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_PASSWORDis 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_KEYis 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.envsomewhere 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:
- Create a Company record and a Person attached to it — this exercises the core data model and confirms writes are landing in Postgres.
- 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.
- 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
dbservice's volume. This is the actual content of your CRM. .env— specificallyENCRYPTION_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.