Workload · Running your own CRM

Self-hosting Twenty CRM: your customer data on a server you control

To self-host Twenty, the open-source CRM, you run the four containers in its official Docker Compose file (the app, a background worker, a PostgreSQL database and Redis) on a Linux server, and the docs name 2 GB of memory as the least it will run on. Below, you keep it unreachable until your own admin account exists, give it an HTTPS address, connect mail and calendars, and keep backups you can restore.

The short version
  1. One compose file, four containers: app, worker, PostgreSQL 16, Redis
  2. Your first sign-up becomes the admin, so do it privately
  3. ENCRYPTION_KEY protects stored tokens: back it up with the data
  4. Updates are a TAG change; migrations run when the app starts
  5. Mostly AGPL-3.0, with Enterprise-marked files under a commercial licence

What you are setting up

Twenty ships a single docker-compose.yml in its repository. It starts four pieces that work together:

  • server: the CRM itself, the web app and its API, listening on port 3000. It also updates the database structure each time it starts.
  • worker: the same image doing background work, such as sending email and syncing mailboxes.
  • db: PostgreSQL 16, where every contact, company and deal lives.
  • redis: a fast queue and cache the other two lean on.

Attachments live in a Docker volume called server-local-data unless you point Twenty at S3 storage. The docs name no processor or disk size, so leave room for attachments to grow.

A server that suits a CRM

A CRM holds every customer's details, and the docs' security page argues for keeping it out of strangers' reach. A dedicated server fits that job: one physical machine, rented whole, with root access and no other customer on the hardware. The port is unmetered, so mailbox and calendar sync never adds a bandwidth line to your invoice. If the database later outgrows the box, it can move to a separate database box.

For one team, the m710x cartridge (Xeon E3-1585L v5, NVMe storage) carries a CRM on its own comfortably, in our view. If Twenty will share the box, for example with n8n running your automations next to it, pick more memory on the m710x (up to 64 GB) or a two-processor server. The X-Gene ARM cartridge won't do: Ubuntu 16.04 and 18.04 are the only systems it installs. The m710x, like the rack servers and blades, includes a remote console that works outside the operating system: its screen and power button, still there when a bad firewall change cuts off SSH. A ready server is yours within 30 minutes of approval; a custom build takes hours, not minutes.

You manage the server; we keep the hardware and the network running.

Up and running in one sitting

Log in, become root with sudo -i, and paste the block below on Ubuntu 24.04 with Docker already installed. It turns on the firewall with only SSH allowed (IPv6 included), downloads the compose file that matches the release you pin, binds the app to the server itself as the docs' security page suggests, and writes the secrets into a file only root can read.

# Firewall first: IPv6 on, SSH allowed, then switch it on
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
# Docker Engine 28+ with the Compose plugin must already be installed (docs.docker.com)
mkdir -p -m 750 /srv/twenty
cd /srv/twenty
REL=v2.50.0   # the release you picked on github.com/twentyhq/twenty/releases
curl -fsSL -o docker-compose.yml \
  "https://raw.githubusercontent.com/twentyhq/twenty/twenty/$REL/packages/twenty-docker/docker-compose.yml"
# keep the app reachable from this machine only
sed -i 's/"3000:3000"/"127.0.0.1:3000:3000"/' docker-compose.yml
# the secrets file exists at mode 600 before anything goes in
install -m 600 /dev/null .env
{
  echo "TAG=$REL"
  echo "SERVER_URL=http://localhost:3000"
  echo "STORAGE_TYPE=local"
  echo "ENCRYPTION_KEY=$(openssl rand -base64 32)"
  echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 32)"
} >> .env
docker compose up -d

About the secrets: ENCRYPTION_KEY locks the OAuth tokens, app variables and two-factor secrets Twenty stores, and losing it, the docs warn, loses all of them. Older guides say to set APP_SECRET; new installs don't need it, as the docs keep it only as a fallback for older instances. The database password is hex because it is pasted unencoded into a connection URL, where characters like @ or / would break it.

Settle on the database password before Twenty first starts, then leave it alone. PostgreSQL stores it in its volume on that first start, and the troubleshooting page's cure for a changed password wipes the database, so it is for brand-new installs only. Why bind to 127.0.0.1: a Docker port with no address listens on 0.0.0.0 and [::], and traffic Docker publishes bypasses ufw for IPv4, plus IPv6 when Docker runs its own IPv6 networking. On 127.0.0.1 the port stays on the machine, which Docker Engine 28+ enforces (moby#45610).

Make yourself the admin, privately

According to its setup docs, whoever signs up first becomes the admin with full privileges, and further sign-ups are switched off once the first workspace exists. That should be you, so sign up through a private SSH tunnel, using your own account on the server:

  1. On your own computer, run ssh -N you@crm-box -L 3000:127.0.0.1:3000 and leave it open.
  2. Open http://localhost:3000 in your browser.
  3. Sign up, name the workspace, and look around. Then stop the tunnel with Ctrl+C.

Everyone else joins later through invitations you send from inside the CRM, which is one more reason to set up outgoing email.

Your own address, with HTTPS

The docs strongly recommend HTTPS through a reverse proxy, and some browser features, such as copy buttons, need it. Do it in this order: the DNS record first, then let Caddy collect the certificate, and only then change SERVER_URL, which Twenty uses for links in emails and for OAuth callbacks. This setup follows the security page's advice to keep the app private: addresses in your office or VPN range get in, and everyone else sees a 403.

# Step 1 (at your DNS provider): crm.yourcompany.com -> this server's IPv4 (A) and IPv6 (AAAA)
# Step 2: Caddy fetches the certificate once the name resolves here
ufw allow 80,443/tcp
apt install -y caddy
tee /etc/caddy/Caddyfile > /dev/null <<'EOF'
crm.yourcompany.com {
	@office remote_ip 198.51.100.0/24
	handle @office {
		reverse_proxy 127.0.0.1:3000
	}
	handle {
		respond 403
	}
}
EOF
systemctl reload caddy
curl -sI https://crm.yourcompany.com | head -1
# Step 3: tell Twenty its real address, then restart it
sed -i 's|^SERVER_URL=.*|SERVER_URL=https://crm.yourcompany.com|' /srv/twenty/.env
docker compose -f /srv/twenty/docker-compose.yml down
docker compose -f /srv/twenty/docker-compose.yml up -d

Expect the curl line to answer 403: the server itself is not on your allowlist, yet the reply arriving over HTTPS shows the certificate works. Swap 198.51.100.0/24 for the addresses your team really uses. Caddy runs directly on the server rather than in Docker, so without that ufw allow line nobody could reach it over either IPv4 or IPv6. Team spread out? The docs also describe Tailscale and Cloudflare Tunnel setups instead of an address list.

Email, Gmail and Outlook calendars

Nearly everything here is set in the app under Settings, then Admin Panel, then Configuration Variables. Start with outgoing mail: set EMAIL_DRIVER to smtp and fill in your mail provider's SMTP host, port, user and password, so invitations and password resets arrive.

To sync Gmail and Google Calendar, create a Google Cloud project, enable the Gmail, Google Calendar and People APIs, and make an OAuth client with the two redirect addresses the setup page lists for your domain (one for sign-in, one for the mail and calendar integration). Then switch on MESSAGING_PROVIDER_GMAIL_ENABLED and CALENDAR_PROVIDER_GOOGLE_ENABLED and enter the client ID and secret. Microsoft 365 works the same way through an Azure app, with one catch from the docs: each person who syncs must hold a Microsoft 365 licence, and personal Microsoft accounts are not supported yet.

Finally, the setup page has you register seven sync jobs inside the worker container, each a yarn command:prod call followed by a job name such as cron:calendar:calendar-events-import. Twenty fetches mail on its own schedule; Google and Microsoft only contact your server if you enable their push webhooks.

Keeping a copy of everything

A useful backup has three parts: the database, the uploaded files, and .env, since without the encryption key the stored tokens are unreadable. The official guide only dumps the database, and its sample command uses names (twenty-postgres, twenty) that the compose file doesn't create; what follows is our own script for this stack. It checks each archive after writing it, and pipefail makes a failed step stop the run. Creating it stops nothing.

mkdir -p -m 700 /var/backups/twenty-crm
install -m 700 /dev/null /srv/twenty/nightly-backup.sh
cat > /srv/twenty/nightly-backup.sh <<'EOF'
set -euo pipefail
umask 077
out=/var/backups/twenty-crm; day=$(date +%F)
dc="docker compose -f /srv/twenty/docker-compose.yml"
$dc exec -T db pg_dump --format=custom --username=postgres default > $out/crm-$day.dump
$dc exec -T db pg_restore --list < $out/crm-$day.dump > /dev/null
docker run --rm --volume twenty_server-local-data:/files:ro --volume $out:/out alpine \
  tar czf /out/uploads-$day.tar.gz -C /files .
tar -tzf $out/uploads-$day.tar.gz > /dev/null
cp /srv/twenty/.env $out/env-$day
EOF
printf '%s\n' '45 1 * * * root umask 077; bash /srv/twenty/nightly-backup.sh > /var/backups/twenty-crm/last-run.log 2>&1' \
  > /etc/cron.d/twenty-crm-backup

There are a few seconds between the database copy and the file archive, so an upload made in that gap may sit in one and not the other. Move the copies to another machine yourself, since they include your secrets; if root's cron does the pushing, create root's SSH key and authorise it on the other machine first, or the job will hang at a password prompt.

The script keeps every copy it makes and never removes old ones. Once they sit safely on another machine, delete old ones yourself, manually or with a cleanup rule you have tried out first, so the disk never fills up.

To restore over the running CRM, as root, with dc="docker compose -f /srv/twenty/docker-compose.yml", out=/var/backups/twenty-crm and day set to the date you want back:

  1. Make sure that backup reads: $dc exec -T db pg_restore --list < $out/crm-$day.dump > /dev/null && tar -tzf $out/uploads-$day.tar.gz > /dev/null && echo ok. Without ok, stop here.
  2. Stop the app and worker: $dc stop server worker.
  3. Save today's state first and test it: set -o pipefail; $dc exec -T db pg_dump --format=custom --username=postgres default > $out/before.dump && $dc exec -T db pg_restore --list < $out/before.dump > /dev/null && echo ok, and docker run --rm --volume twenty_server-local-data:/files:ro --volume $out:/out alpine tar czf /out/before-uploads.tar.gz -C /files . && tar -tzf $out/before-uploads.tar.gz > /dev/null && echo ok. Only carry on after two oks.
  4. Replace the database: $dc exec -T db dropdb --username=postgres default, then $dc exec -T db createdb --username=postgres default, then $dc exec -T db pg_restore --username=postgres --dbname=default < $out/crm-$day.dump.
  5. Put the uploads back: docker run --rm --volume twenty_server-local-data:/files --volume $out:/in alpine sh -c "find /files -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar xzf /in/uploads-$day.tar.gz -C /files". Extracting as root keeps each file's original owner.
  6. Start again with $dc up -d; once $dc ps reports the server as healthy, check curl --fail --silent http://127.0.0.1:3000/healthz and sign in. Keep before.dump and before-uploads.tar.gz until you are happy: restored the same way, they undo the restore.

Restoring onto a fresh server? Copy the saved .env into /srv/twenty before the database container starts for the first time, so PostgreSQL is created with the password your dump expects. Start only db, restore, then start the rest.

Updating without surprises

Pinning TAG means you update on purpose, which the security page asks for. The upgrade guide's routine is short. Take a backup, stop everything with docker compose -f /srv/twenty/docker-compose.yml down, set a new TAG value in /srv/twenty/.env, and bring it back with docker compose -f /srv/twenty/docker-compose.yml up -d. The server applies the database changes on startup, with nothing to run by hand. We also download the new release's compose file and repeat the 127.0.0.1 edit, so file and image stay in step. The guide's upgrade:status command, run through yarn command:prod in the server container, shows where each workspace stands.

Three warnings from the guide, depending on where you start:

  • Below v1.23, you must step through every tagged version in turn (v1.6, v1.7, v1.8 and on) until you reach v1.23; from v1.23, one jump to the newest release works.
  • At v2.5, stored secrets are rewrapped under the encryption key, and the first start runs a slow backfill that resumes if interrupted. Set your own ENCRYPTION_KEY before this step, or changing it later means a key rotation.
  • From v2.34, Twenty needs PostgreSQL 15 at least. The bundled postgres:16 is fine; upgrade an outside database beforehand.

The guide doesn't describe going back. Our reading: to undo an update, return to the old TAG and restore the backup you took just before it. For critical security fixes, the security page says to update the same day.

What the licence lets you do

Twenty's LICENSE file puts most of the project under the GNU AGPL v3. Two exceptions: files that begin with /* @license Enterprise */ fall under the Twenty.com Commercial License, so running them in production requires an Enterprise Edition subscription that covers your hosts and seats (development and testing need none); and some packages are MIT, among them the CLI and SDKs, twenty-ui and the apps in packages/twenty-apps. A further permission keeps apps that only talk to Twenty through its APIs, webhooks and SDKs free to use any licence.

For you as an operator, here is our general reading of these terms, which is not legal advice. Running Twenty unchanged for your own team creates no duty at all. Hand copies to other people, changed or unchanged, and the source has to go with them. Change the code and let people use your version over the network, and section 13 requires offering your changed source to those users. The MIT parts only ask you to keep the copyright and permission notice with any copy.

Questions people ask

Can strangers create accounts on my CRM?
Not in the default one-workspace mode once you have signed up: the docs say sign-ups switch off after the first workspace exists, and others join by invitation. The risk is the moment before that, which is why your own sign-up goes through an SSH tunnel.
Is single sign-on available when you self-host?
Google and Microsoft sign-in are switched on with variables described in the self-hosting guide. For SAML, Google Workspace or Entra ID single sign-on, the user guide's SSO page says you need the Organization plan, whether your workspace is in the cloud or on your own server. In the twenty-server package, the file core-modules/sso/services/sso.service.ts starts with the /* @license Enterprise */ header.
What happens if I lose ENCRYPTION_KEY?
Per the docs, you lose access to every secret Twenty stored with it: connected-account OAuth tokens, application variables, two-factor secrets and more. Keep a copy of .env with each backup, off the server. Rotating the key is a documented procedure, not a matter of editing the value.

Give your CRM a server of its own

Choose a dedicated server, put Ubuntu 24.04 on it, and have Twenty running for your team in one sitting once it is handed over.

Get a server for your CRM

Read next