SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Self-host Chatwoot on a VPS with Docker

Deploy Chatwoot on a VPS with Docker Compose and Traefik: pinned tags, SMTP that actually sends, Postgres and uploads backups, and safe upgrades.

What you are building

To self-host Chatwoot on a VPS you run four containers: a Rails web process, a Sidekiq background worker, PostgreSQL with the pgvector extension, and Redis. Chatwoot is an open source customer support desk, so you get a shared team inbox and a website chat widget on a server you control. The install takes about twenty minutes. Everything after it, mail delivery, backups, upgrades and sizing, is what decides whether the thing is still running in a year.

Each container has one job. Rails serves the agent dashboard and the widget API (application programming interface). Sidekiq runs the slow work: sending email, polling connected channels, running automation rules and building reports. Postgres holds conversations, contacts, agent accounts and every setting you change in the dashboard. Redis holds the Sidekiq queues and the ActionCable pub/sub channel that pushes a new message into an open dashboard without a page reload. Redis is not a throwaway cache here, because losing it means losing queued jobs.

The Postgres image in the upstream compose file is pgvector/pgvector:pg16 rather than the stock postgres image, because Chatwoot's schema enables the vector extension for its AI features. Swap in stock Postgres and the first database run stops with ERROR: extension "vector" is not available, because the extension's control file is not in that image. Use the image upstream ships.

This guide assumes Docker and a reverse proxy already work on the box. If they do not, start with Docker Compose on a VPS and come back.

How much VPS does a self-hosted Chatwoot need?

As of August 2026, the upstream requirements page asks for 4 GB of RAM and 4 CPU cores as the minimum, and rates that at up to 10,000 conversations a day. It puts 8 GB and 8 cores at up to 20,000 a day. It also asks for at least 1 GB of swap, and gives the reason directly: so the machine does not run out of memory during an upgrade. Plan on 5 GB to 10 GB of disk for Postgres before you count file uploads.

Now the blunt part. A 2 GB VPS will boot Chatwoot, and it looks fine with two agents and a quiet inbox. It falls over in two places. The first is Sidekiq, which upstream measures at over 1 GB on a busy server, so a burst of email or a report job pushes the box past its memory before Rails, Postgres and Redis have taken their share. The second is the upgrade, because db:chatwoot_prepare boots a fresh Rails process to apply migrations, and a Rails boot on this image costs hundreds of megabytes before it does any useful work.

You do not get a polite warning first. The kernel's out of memory killer sends SIGKILL to the largest process, Docker sees the container die, and restart: always starts it again. docker compose ps then shows a container that keeps returning to Exited (137), where 137 means killed by signal 9. Confirm it with sudo dmesg -T | grep -i "killed process", which names the process the kernel picked.

If 4 GB is out of budget, run a 2 GB box with 2 GB of swap and accept that response times get worse under load rather than the service dying outright. Setting a hard memory ceiling per service is worth doing either way, so the worker cannot take the database down with it. See memory limits in Docker Compose.

File uploads are the part that grows without a limit you set. Every screenshot a customer attaches lands in the storage volume and stays there, so watch docker system df -v rather than assuming the database is what filled the disk.

Get the compose file and pin a version tag

mkdir -p ~/chatwoot && cd ~/chatwoot
wget -O .env https://raw.githubusercontent.com/chatwoot/chatwoot/develop/.env.example
wget -O docker-compose.yaml https://raw.githubusercontent.com/chatwoot/chatwoot/develop/docker-compose.production.yaml
chmod 600 .env

The file you just downloaded says image: chatwoot/chatwoot:latest. Change that before you do anything else.

services:
  base: &base
    image: chatwoot/chatwoot:v4.16.2
    env_file: .env
    volumes:
      - storage_data:/app/storage

latest means the next docker compose pull hands you whatever was published that morning, which can be a major version with migrations you never read about. Chatwoot migrations are not reversible in practice, so an accidental jump is a restore from backup, not an undo. Pin the tag and change it deliberately. v4.16.2 was the current release as of August 2026; check the releases page for the tag you should pin today.

The base service is a YAML anchor that rails and sidekiq both merge, so changing the tag in one place changes it for both. While you are in the file, delete the version: '3' line at the top. Modern Compose ignores it and prints the attribute 'version' is obsolete, it will be ignored on every command.

Fill in the .env file

Generate the secret first. Upstream asks for an alphanumeric value, because special characters get mangled when the value passes through a shell or a YAML parser.

head /dev/urandom | tr -dc A-Za-z0-9 | head -c 63 ; echo ''

Then set these keys in .env.

SECRET_KEY_BASE=<the 63 characters you just generated>
FRONTEND_URL=https://support.example.com
FORCE_SSL=true
DEFAULT_LOCALE=en
ENABLE_ACCOUNT_SIGNUP=true

POSTGRES_HOST=postgres
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=<long random string>
POSTGRES_DATABASE=chatwoot

REDIS_URL=redis://redis:6379
REDIS_PASSWORD=<a different long random string>

RAILS_ENV=production
INSTALLATION_ENV=docker
ACTIVE_STORAGE_SERVICE=local

POSTGRES_HOST=postgres and redis://redis:6379 are the Compose service names, which resolve on the project's default network. FRONTEND_URL is not decoration. Chatwoot builds the widget script URL and every link inside an outgoing email from it, so a wrong value gives you password reset links pointing at a host that does not answer.

Now the trap in the upstream file. The postgres service does not read .env. It carries its own environment block with POSTGRES_PASSWORD= left empty, so setting the password in .env alone leaves the database with no password and the application with one. Point the service at the same variable:

  postgres:
    image: pgvector/pgvector:pg16
    restart: always
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=chatwoot
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}

Compose reads .env from the project directory for ${...} substitution, so both sides now get the same string. Get this wrong and Rails stops with PG::ConnectionBad: FATAL: password authentication failed for user "postgres".

One behaviour surprises almost everyone: the Postgres image only applies POSTGRES_PASSWORD when it initialises an empty data directory. Changing the value later has no effect, because initdb never runs a second time. If you have already started the stack once, change it inside the database instead.

docker compose exec postgres psql -U postgres -c "ALTER USER postgres WITH PASSWORD 'the-new-password';"

ENABLE_ACCOUNT_SIGNUP=true is temporary. It opens the public registration form so you can create the first account. Set it to false and run docker compose up -d again as soon as your account exists, or anyone who finds the URL can register on your support desk.

.env now holds every secret this stack has, in plain text, so keep it at mode 600 and keep it out of git. How Compose reads env files, and where secrets leak covers the sharp edges, including the difference between env_file and environment.

Put Chatwoot behind your existing Traefik

Do not build a second reverse proxy for one application. If Traefik already terminates TLS (transport layer security) for other containers on this box, Chatwoot joins it with a label block. If you do not have that yet, set it up once with Traefik in front of several Docker Compose apps, then come back here.

Keep upstream's docker-compose.yaml close to stock so you can diff it against a newer copy later, and put your changes in an override file. Compose merges docker-compose.override.yaml automatically, and splitting Compose across multiple files explains the merge rules.

services:
  rails:
    networks:
      - default
      - proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.chatwoot.rule=Host(`support.example.com`)"
      - "traefik.http.routers.chatwoot.entrypoints=websecure"
      - "traefik.http.routers.chatwoot.tls.certresolver=letsencrypt"
      - "traefik.http.services.chatwoot.loadbalancer.server.port=3000"

networks:
  proxy:
    external: true

Use your own entrypoint and certresolver names. The container must sit on the same Docker network as Traefik, which is what the proxy entry does, and it must stay on default as well or it loses Postgres and Redis. That second line is the one people forget.

Leave the ports: block alone. Upstream binds it to 127.0.0.1:3000, which is loopback only, so it is not reachable from the internet and it stays useful for testing from inside the box with curl -I http://127.0.0.1:3000.

The agent dashboard keeps a websocket open to /cable for live message delivery. Traefik forwards the HTTP upgrade with no extra configuration, so there is nothing to add. If you later put a CDN or another proxy in front of Traefik, allow websockets there, because the symptom is a dashboard that loads normally while new messages only appear after a manual refresh.

Initialise the database and start the stack

Bring up the data services first and let Postgres finish its first run.

docker compose up -d postgres redis
docker compose logs postgres | tail -n 5

Wait for database system is ready to accept connections. Then create the schema.

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

This creates the database if it is missing, then loads the schema and the default seed data. It prints migration lines and exits cleanly. If it sits printing postgres:5432 - no response, the entrypoint is waiting on a database that is not accepting connections yet, which on a first run usually means initdb is still working. Wait, read the Postgres logs, then run it again. If it stops on the vector extension, you replaced the pgvector image with stock Postgres.

docker compose up -d
docker compose ps
docker compose logs --tail 30 rails

All four containers should read Up, and the rails log should end with a Puma line listening on http://0.0.0.0:3000. Then check the public path:

curl -sI https://support.example.com | head -n 1

HTTP/2 200 means the whole chain works. A 404 from Traefik means the router rule did not match, usually a hostname typo. A 502 means Traefik matched the router and then could not reach the container, which is almost always the missing proxy network or a loadbalancer.server.port that is not 3000.

Open the URL, create your account at /app/auth/signup, then set ENABLE_ACCOUNT_SIGNUP=false and run docker compose up -d to close the form.

Why password resets and email conversations fail without SMTP

Chatwoot with no SMTP (simple mail transfer protocol) settings is a support desk that cannot send mail, and that breaks more than notifications. Password resets stop working, so an admin who gets locked out stays locked out. Agent invitations stop working, because an invitation is an email. Replying to a customer in an email conversation stops working, so the conversation only runs one way. This is the step people skip and then discover during their worst week.

The mechanism is plain. With no SMTP settings, ActionMailer keeps its default of delivering to localhost on port 25. There is no mail server inside the Rails container, so the delivery job raises Errno::ECONNREFUSED: Connection refused - connect(2) for "localhost" port 25. Mail goes out from a background job, so that line lands in the Sidekiq log and never in the Rails log. Meanwhile the person clicking "forgot password" sees a cheerful confirmation and receives nothing.

MAILER_SENDER_EMAIL=Support <support@example.com>
SMTP_DOMAIN=example.com
SMTP_ADDRESS=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=support@example.com
SMTP_PASSWORD=<the relay password>
SMTP_AUTHENTICATION=plain
SMTP_ENABLE_STARTTLS_AUTO=true

Use port 587 with STARTTLS, which opens the connection in plain text and upgrades it to encrypted before authentication. Most VPS providers block outbound port 25 to limit spam, so a relay on 587 is usually the only thing that will connect at all. SMTP_DOMAIN is the domain your server announces during the SMTP conversation, and some relays reject a mismatch.

Apply the settings and watch the worker:

docker compose up -d rails sidekiq
docker compose logs -f sidekiq

Trigger a password reset from the login page. A working delivery shows the mailer job finishing normally in the Sidekiq log. A failure shows the exception class, then Sidekiq retrying with a growing backoff, which is why a broken relay produces the same error every few minutes for hours.

Two rejections are common, and neither is a Chatwoot bug. 535 Authentication failed means the username or password is wrong for that relay, and many providers want an application password rather than the account password. 550 Sender address rejected means MAILER_SENDER_EMAIL is an address the relay will not send as, so it has to be a mailbox or domain you have verified with them.

Receiving email into a conversation is a separate job. It needs MAILER_INBOUND_EMAIL_DOMAIN and RAILS_INBOUND_EMAIL_SERVICE, plus a mail server that hands incoming messages to Chatwoot. Renting a relay is the fast path. If you would rather own the whole mail path, running your own mail server with Mailcow covers what that commitment actually involves.

What to back up, and how to prove the restore works

A Chatwoot backup has four parts, and skipping any one of them turns a restore into a rebuild.

  • The Postgres database, which holds conversations, contacts, agent accounts and every setting.
  • The storage_data volume, because ACTIVE_STORAGE_SERVICE=local writes uploaded files to disk and keeps only a reference row in Postgres.
  • The .env file, because it holds SECRET_KEY_BASE and the ACTIVE_RECORD_ENCRYPTION_* keys.
  • The compose files, because they record the exact image tag your database schema matches.

Restore the database on its own and every conversation comes back with broken attachments, because the rows point at files that are no longer on disk.

cd ~/chatwoot
docker compose exec -T postgres pg_dump -U postgres -Fc chatwoot > db-$(date +%F).dump

-T matters. Without it Compose allocates a pseudo terminal, which rewrites newline bytes in the stream, so you get a dump file that pg_restore rejects. -Fc is the custom format, which compresses and lets pg_restore work selectively.

docker run --rm -v chatwoot_storage_data:/data:ro -v "$PWD":/backup alpine \
  tar czf /backup/storage-$(date +%F).tgz -C /data .

The volume name is your project directory name plus _storage_data. Confirm it with docker volume ls | grep storage_data before you trust that command, because Docker creates an empty volume rather than failing when you name one that does not exist. You would get a valid, empty archive and no error at all. Check the size afterwards with ls -lh storage-*.tgz.

Both files now sit on the same disk as the thing they protect, which protects you from nothing. Push them off the box and encrypt them, because a database dump contains every customer message in plain text. Encrypted off-site backups with restic covers the scheduling and retention side.

The restore drill, run it before you need it

Restore onto a second VPS, not onto the live one. Copy .env, the compose files and both archives across, then run:

docker compose up -d postgres
docker compose exec -T postgres pg_restore -U postgres -d chatwoot --clean --if-exists < db-2026-08-10.dump
docker run --rm -v chatwoot_storage_data:/data -v "$PWD":/backup alpine \
  sh -c 'rm -rf /data/* && tar xzf /backup/storage-2026-08-10.tgz -C /data'
docker compose up -d

--clean --if-exists drops the existing objects before loading, so only point it at a database you are willing to lose. Then sign in and open a conversation that has an attachment. If the message list loads and the file downloads, the backup is real.

A restore with a different SECRET_KEY_BASE invalidates every session cookie, so everyone is signed out. A restore with different ACTIVE_RECORD_ENCRYPTION_* keys is worse: Chatwoot cannot decrypt the columns holding channel credentials and raises ActiveRecord::Encryption::Errors::Decryption. That is why .env is on the backup list.

How to upgrade Chatwoot to a new tag

The order matters more than the commands do.

  1. Read the release notes between your tag and the target, looking for required manual steps.
  2. Take a fresh database dump and storage archive, and check both file sizes look sane.
  3. Edit the image tag on the base service in docker-compose.yaml.
  4. Pull the new image, stop the stack, run migrations, then start again.
docker compose pull
docker compose down
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
docker compose up -d
docker compose images

Pull before you migrate, because the migration has to run from the new image: the old image does not contain the new migration files. Stop the stack before you migrate, because the old code and the new schema disagree, so a running old Rails process can raise errors or write rows the new schema will not accept. Stopping also frees the memory the migration needs, which is the whole reason upstream asks for swap.

docker compose images prints the tag each container is actually running, which catches the case where you edited the tag and forgot to pull.

Do not jump across many versions at once. Upstream's advice for an old install is to step through intermediate tags, because migrations get removed once they are folded into the base schema, so a very old database can reach a state with no path forward. Move one minor version at a time and run the prepare step after each one.

If Rails starts before the migration has run, it refuses to serve and logs ActiveRecord::PendingMigrationError: Migrations are pending. With restart: always set, the container then cycles, so docker compose ps shows an uptime that resets every few seconds. Run the prepare step and it clears.

Rolling back means putting the old tag back and restoring the dump. There is no reverse migration path you can rely on, which is what step 2 is for.

Failure modes and the strings you will see

502 Bad Gateway from Traefik. The router matched and the backend did not answer. Check docker compose ps shows rails as Up, then run docker network inspect proxy and confirm the rails container appears in its container list. A container that is not attached is invisible to Traefik, so the request matches a router and then goes nowhere.

The dashboard loads but new messages need a refresh. The websocket to /cable is not getting through, or FRONTEND_URL does not match the address in the browser bar. A mismatch means the page tries to open a websocket to a different origin, which the browser blocks.

FATAL: password authentication failed for user "postgres". The password in .env and the one baked into the Postgres data volume differ. Fix it with ALTER USER inside the running container, because editing .env again will not change an already initialised database.

NOAUTH Authentication required. Redis is running with --requirepass but the application connected without a password, so REDIS_PASSWORD is missing from .env or was not picked up. Test it directly with docker compose exec redis redis-cli -a "$REDIS_PASSWORD" ping, which should answer PONG.

Containers exiting with code 137. That is SIGKILL, and on a small box it is the kernel's out of memory killer. Add swap, set per-service memory limits, or move to a larger plan.

FAQ

How much RAM does a self-hosted Chatwoot VPS need?

As of August 2026, upstream asks for 4 GB of RAM and 4 CPU cores as the minimum, rated at up to 10,000 conversations a day, and 8 GB with 8 cores for up to 20,000. Add at least 1 GB of swap, because an upgrade runs a second Rails process to apply migrations and that is where small boxes run out of memory. A 2 GB VPS boots and works for a couple of agents, but Sidekiq alone can pass 1 GB under load, so expect containers killed with exit code 137 during busy periods and during upgrades.

Why do Chatwoot password reset emails never arrive?

Because no SMTP settings are configured, so ActionMailer tries to deliver to localhost on port 25 and there is no mail server inside the container. The job fails in Sidekiq with Errno::ECONNREFUSED: Connection refused - connect(2) for "localhost" port 25 while the browser still shows a success message. Set SMTP_ADDRESS, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD and MAILER_SENDER_EMAIL in .env, restart the rails and sidekiq services, then watch docker compose logs -f sidekiq while you trigger a reset.

What do I need to back up to restore Chatwoot?

The Postgres database, the storage_data Docker volume, the .env file and the compose files. The database alone is not enough, because uploaded files live in the volume while Postgres holds only references to them, so a database-only restore gives you conversations with broken attachments. .env matters because a different SECRET_KEY_BASE signs every user out, and different ACTIVE_RECORD_ENCRYPTION_* keys make the encrypted columns unreadable.

How do I upgrade Chatwoot without breaking the database?

Take a backup, change the image tag in your compose file, then run docker compose pull, docker compose down, docker compose run --rm rails bundle exec rails db:chatwoot_prepare and docker compose up -d. Pull first because migrations must run from the new image, and stop the stack first because old code against a new schema raises errors. On an old install, move one minor version at a time, since migrations get removed once they are folded into the base schema.

Can I use the standard postgres image instead of pgvector?

No. Chatwoot's schema enables the vector extension, so the stock postgres image fails during db:chatwoot_prepare with ERROR: extension "vector" is not available, because the extension's control file is not present in that image. Keep pgvector/pgvector:pg16 from the upstream compose file, or use another image that ships pgvector for your Postgres major version.

#chatwoot#self-hosting#docker-compose#support-desk#smtp#backups