How to Self-Host Chatwoot on VPS with Docker
Run Chatwoot for VPS with Docker Compose and Traefik, using pinned tags, working SMTP, Postgres and uploads backups, plus safer upgrades.
Wetin you dey build
To self-host Chatwoot for VPS, you go run four containers: one Rails web process, one Sidekiq background worker, PostgreSQL with the pgvector extension, and Redis. Chatwoot na open source customer support desk, so you go get shared team inbox and website chat widget for server wey you control. The installation dey take about twenty minutes. Everything wey follow am, like mail delivery, backups, upgrades, and sizing, na wetin go determine whether the thing still dey run after one year.
Each container get one work. Rails dey serve agent dashboard and widget API (application programming interface). Sidekiq dey run slow jobs: e dey send email, poll connected channels, run automation rules, and build reports. Postgres dey store conversations, contacts, agent accounts, and every setting wey you change for dashboard. Redis dey hold Sidekiq queues and the ActionCable pub/sub channel wey dey push new message enter open dashboard without page reload. Redis no be disposable cache for here, because if e loss, queued jobs go loss too.
The Postgres image for the upstream compose file na pgvector/pgvector:pg16 instead of the normal postgres image, because Chatwoot schema dey enable vector extension for its AI features. If you replace am with stock Postgres, the first database run go stop with ERROR: extension "vector" is not available, because that image no get the extension control file. Use the image wey upstream ships.
This guide assume say Docker and reverse proxy already dey work for the server. If dem no dey work, start with Docker Compose for VPS and come back.
Chatwoot wey you self-host need VPS size of how much?
As of August 2026, upstream requirements page dey ask for 4 GB of RAM and 4 CPU cores as the minimum, and e rate am for up to 10,000 conversations per day. E put 8 GB and 8 cores at up to 20,000 per day. E also ask for at least 1 GB of swap, and e give the reason directly: so the machine no go run out of memory during upgrade. Plan for 5 GB to 10 GB of disk space for Postgres before you count file uploads.
Now make we talk plainly. 2 GB VPS go boot Chatwoot, and e fit look fine with two agents and quiet inbox. But e dey fail for two places. The first one na Sidekiq, wey upstream measure say e dey use over 1 GB for busy server. So burst of email or report job fit push the box pass the available memory before Rails, Postgres, and Redis take their own share. The second one na upgrade, because db:chatwoot_prepare boots fresh Rails process to apply migrations, and Rails boot for this image dey cost hundreds of megabytes before e do any useful work.
You no go first get polite warning. Kernel out of memory killer go send SIGKILL to the biggest process. Docker go see say the container die, and restart: always go start am again. docker compose ps go then show container wey dey return again and again to Exited (137), where 137 mean say signal 9 kill am. Confirm am with sudo dmesg -T | grep -i "killed process", wey go name the process kernel pick.
If 4 GB pass your budget, you fit run 2 GB box with 2 GB of swap. Accept say response time go worse under load instead of the service dying completely. Setting hard memory ceiling for each service make sense either way, so the worker no go carry the database down with am. See memory limits for Docker Compose.
File uploads na the part wey dey grow without limit wey you set. Every screenshot customer attach go enter the storage volume and remain there. So monitor docker system df -v instead of assuming say na database fill the disk.
Get compose file and lock one 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 .envThe file wey you just download talk say image: chatwoot/chatwoot:latest. Change am before you do anything else.
services:
base: &base
image: chatwoot/chatwoot:v4.16.2
env_file: .env
volumes:
- storage_data:/app/storagelatest mean say the next docker compose pull go give you anything wey dem publish that morning. E fit be a major version with migrations wey you never read about. For practical use, you no fit reverse Chatwoot migrations. So accidental jump mean say you go restore from backup, no be undo. Lock the tag and change am deliberately. v4.16.2 na the current release as of August 2026. Check the releases page to know the tag wey you suppose lock today.
The base service na YAML anchor wey rails and sidekiq both merge. So if you change the tag for one place, e go change am for both. As you dey inside the file, delete the version: '3' line for the top. Modern Compose ignore am and print the attribute 'version' is obsolete, it will be ignored for every command.
Fill in the .env file
First generate the secret. Upstream require alphanumeric value, because special characters fit spoil when value pass through shell or YAML parser.
head /dev/urandom | tr -dc A-Za-z0-9 | head -c 63 ; echo ''Then set these keys inside .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=localPOSTGRES_HOST=postgres and redis://redis:6379 na the Compose service names wey resolve for the project's default network. FRONTEND_URL no be decoration. Chatwoot dey use am build the widget script URL and every link inside outgoing email. So if the value wrong, password reset links go point to host wey no dey answer.
Now see the trap for the upstream file. The postgres service no dey read .env. E get im own environment block, and POSTGRES_PASSWORD= dey empty. So if you set password for .env alone, database go get no password while application get one. Point the service to 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 dey read .env from the project directory for ${...} substitution. So both sides go get the same string now. If you set am wrong, Rails go stop with PG::ConnectionBad: FATAL: password authentication failed for user "postgres".
One behaviour dey surprise almost everybody: the Postgres image only applies POSTGRES_PASSWORD when e dey initialise an empty data directory. If you change the value later, e no go get effect, because initdb no dey run again. If you don start the stack before, change am inside the database instead.
docker compose exec postgres psql -U postgres -c "ALTER USER postgres WITH PASSWORD 'the-new-password';"ENABLE_ACCOUNT_SIGNUP=true na temporary. E opens the public registration form so you fit create the first account. Set am to false and run docker compose up -d again immediately your account don exist. Otherwise, anybody wey find the URL fit register for your support desk. From then, agents go come through invitation, and their passwords go live only inside this one app. That arrangement dey okay until you dey run about half a dozen services and you don tire to maintain separate account list for each one. For that point, a self-hosted identity provider like Authentik na the piece wey replaces dem.
.env now contain every secret wey this stack get, as plain text. So set the file mode to 600 and keep am out of git. How Compose reads env files, and where secrets leak explain the risky parts, including the difference between env_file and environment.
Put Chatwoot behind your existing Traefik
No build second reverse proxy for one application. If Traefik already dey terminate TLS (transport layer security) for other containers for this box, Chatwoot go join am with one label block. If you never set am up, set am up once with Traefik for front of several Docker Compose apps, then come back here.
Keep upstream's docker-compose.yaml close to the original so you fit compare am with newer copy later, and put your changes for override file. Compose dey merge docker-compose.override.yaml automatically, and how to split Compose across multiple files dey explain 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: trueUse your own entrypoint and certresolver names. The container must dey for the same Docker network with Traefik. Na the proxy entry dey do this, and e must still dey for default too, or e go lose Postgres and Redis. Na this second line many people dey forget.
Leave the ports: block as e be. Upstream binds am to 127.0.0.1:3000, and na only loopback fit reach am. So internet no fit reach am, and e still useful for testing from inside the box with curl -I http://127.0.0.1:3000.
The agent dashboard dey keep websocket open to /cable so e fit receive new messages live. Traefik forwards the HTTP upgrade without extra configuration, so you no need add anything. If you later put CDN or another proxy for front of Traefik, allow websockets there. The symptom na dashboard wey load normally, but new messages only show after manual refresh.
Database initialise and the stack start
Bring the data services up first, then allow Postgres finish the first run.
docker compose up -d postgres redis
docker compose logs postgres | tail -n 5Wait for database system is ready to accept connections. Then create the schema.
docker compose run --rm rails bundle exec rails db:chatwoot_prepareThis one go create the database if e no dey, then load the schema and the default seed data. E go print migration lines and exit cleanly. If e dey print postgres:5432 - no response without stopping, the entrypoint dey wait for database wey never ready to accept connections. For first run, this usually mean say initdb still dey work. Wait, read the Postgres logs, then run am again. If e stop for the vector extension, you don replace the pgvector image with stock Postgres.
docker compose up -d
docker compose ps
docker compose logs --tail 30 railsAll four containers suppose show Up, and the rails log suppose end with a Puma line wey dey listen on http://0.0.0.0:3000. Then check the public path:
curl -sI https://support.example.com | head -n 1HTTP/2 200 mean say the whole chain dey work. If Traefik return 404, the router rule no match. Hostname typo na the usual cause. If e return 502, Traefik match the router but e no fit reach the container. This almost always mean say the proxy network dey missing, or loadbalancer.server.port no be 3000.
Open the URL, create your account for /app/auth/signup, then set ENABLE_ACCOUNT_SIGNUP=false and run docker compose up -d to close the form.
Wetin make password reset and email conversation fail without SMTP
Chatwoot wey no get SMTP (simple mail transfer protocol) settings na support desk wey no fit send mail, and this one spoil more than notifications. Password reset go stop to work, so admin wey lock out no go fit enter again. Agent invitation go stop to work too, because invitation na email. Replying customer for email conversation go stop to work, so the conversation go only move one way. Na this step people dey skip, then dem discover am during their worst week.
The process clear. When SMTP settings no dey, ActionMailer keep the default of delivering to localhost for port 25. No mail server dey inside the Rails container, so the delivery job raise Errno::ECONNREFUSED: Connection refused - connect(2) for "localhost" port 25. Mail dey go out from background job, so that line go enter Sidekiq log and never enter Rails log. Meanwhile, the person wey click "forgot password" go see happy confirmation but receive 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=trueUse port 587 with STARTTLS. E go first open the connection as plain text, then upgrade am to encrypted connection before authentication. Most VPS providers dey block outbound port 25 to reduce spam, so relay for 587 usually na the only option wey go connect at all. SMTP_DOMAIN na the domain wey your server announce during SMTP conversation, and some relays go reject am if e no match.
Apply the settings and monitor the worker:
docker compose up -d rails sidekiq
docker compose logs -f sidekiqStart password reset from the login page. When delivery work, mailer job go finish normally for Sidekiq log. When e fail, log go show the exception class, then Sidekiq go retry with longer backoff each time. Na why broken relay dey produce the same error every few minutes for hours.
Two rejection types common, and neither one na Chatwoot bug. 535 Authentication failed mean say username or password wrong for that relay. Many providers want application password instead of the account password. 550 Sender address rejected mean say MAILER_SENDER_EMAIL na address wey the relay no go send as, so e must be mailbox or domain wey you don verify with dem.
Receiving email inside conversation na separate work. E need MAILER_INBOUND_EMAIL_DOMAIN and RAILS_INBOUND_EMAIL_SERVICE, plus mail server wey go hand incoming messages over to Chatwoot. Renting relay na the fast option. If you prefer to manage the whole mail path yourself, running your own mail server with Mailcow explain wetin that responsibility involve.
Wetin to back up, and how to prove say restore dey work
Chatwoot backup get four parts, and if you skip any one, restore go turn rebuild.
- The Postgres database, wey dey hold conversations, contacts, agent accounts and every setting.
- The
storage_datavolume, becauseACTIVE_STORAGE_SERVICE=localdey write uploaded files to disk and keep only reference row for Postgres. - The
.envfile, because e dey holdSECRET_KEY_BASEand theACTIVE_RECORD_ENCRYPTION_*keys. - The compose files, because dem dey record the exact image tag wey your database schema match.
If you restore the database alone, every conversation go come back with broken attachments, because the rows dey point to files wey no dey disk again.
cd ~/chatwoot
docker compose exec -T postgres pg_dump -U postgres -Fc chatwoot > db-$(date +%F).dump-T matter. Without am, Compose go allocate pseudo terminal, wey go rewrite newline bytes for the stream. So you go get dump file wey pg_restore no fit accept. -Fc na the custom format. E dey compress the data and allow 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 na your project directory name plus _storage_data. Confirm am with docker volume ls | grep storage_data before you trust that command, because Docker go create empty volume instead of failing when you name one wey no exist. You go get valid, empty archive and no error at all. Check the size afterwards with ls -lh storage-*.tgz.
Both files now dey the same disk with the thing wey dem suppose protect, so dem no protect you from anything. Push dem outside the server and encrypt dem, because database dump dey contain every customer message as plain text. Encrypted off-site backups with restic dey explain the scheduling and retention side.
The restore drill, run am before you need am
Restore am onto second VPS, no be the live one. Copy .env, the compose files and both archives go there, 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 go drop the existing objects before e load the data, so point am only to database wey you ready to lose. Then sign in and open conversation wey get attachment. If message list load and the file download, the backup dey real.
Restore with different SECRET_KEY_BASE go invalidate every session cookie, so everybody go sign out. Restore with different ACTIVE_RECORD_ENCRYPTION_* keys worse pass: Chatwoot no fit decrypt the columns wey hold channel credentials and e go raise ActiveRecord::Encryption::Errors::Decryption. Na why .env dey the backup list.
How to upgrade Chatwoot to a new tag
The order matter pass the commands.
- Read the release notes between your tag and the target, and look for manual steps wey you must do.
- Take fresh database dump and storage archive, then check say both file sizes dey reasonable.
- Edit the image tag for the
baseservice insidedocker-compose.yaml. - Pull the new image, stop the stack, run migrations, then start am again.
docker compose pull
docker compose down
docker compose run --rm rails bundle exec rails db:chatwoot_prepare
docker compose up -d
docker compose imagesPull before you migrate, because migration must run from the new image: the old image no get the new migration files. Stop the stack before you migrate, because the old code and the new schema no agree. So, old Rails process wey still dey run fit raise errors or write rows wey the new schema no go accept. Stopping also free the memory wey migration need. Na this be the reason upstream ask for swap.
docker compose images prints the tag wey each container dey run in practice. This one catch the case where you edit the tag but forget to pull.
No jump across many versions at once. Upstream advice for old install na to pass through intermediate tags, because dem remove migrations after dem don fold dem into the base schema. Because of this, very old database fit reach state wey no path forward dey. Move one minor version at a time, then run the prepare step after each one.
If Rails start before migration run, e refuse to serve and e log ActiveRecord::PendingMigrationError: Migrations are pending. When restart: always dey set, the container go cycle, so docker compose ps go show uptime wey reset every few seconds. Run the prepare step and e go clear.
Rollback mean say you put the old tag back and restore the dump. No reverse migration path dey wey you fit rely on. Na why step 2 dey important.
Failure modes and the strings you will see
502 Bad Gateway from Traefik. Router match, but backend no answer. Check docker compose ps show rails as Up, then run docker network inspect proxy and confirm say rails container dey inside the container list. If container no attach, Traefik no fit see am, so request go match router but e no go anywhere.
The dashboard loads but new messages need a refresh. Websocket go /cable no dey pass, or FRONTEND_URL no match the address wey dey browser bar. If dem no match, page go try open websocket to another origin, and browser go block am.
FATAL: password authentication failed for user "postgres". Password for .env and the one wey dey inside Postgres data volume no be the same. Fix am with ALTER USER inside the running container, because editing .env again no go change database wey don already initialise.
NOAUTH Authentication required. Redis dey run with --requirepass, but application connect without password, so REDIS_PASSWORD dey miss from .env or application no pick am up. Test am directly with docker compose exec redis redis-cli -a "$REDIS_PASSWORD" ping; e suppose answer PONG.
Containers dey exit with code 137. Na SIGKILL, and for small server na kernel out of memory killer cause am. Add swap, set memory limits for each service, or move go bigger plan.
FAQ
How much RAM self-hosted Chatwoot VPS need?
As of August 2026, upstream dey ask for 4 GB RAM and 4 CPU cores as minimum, rated for up to 10,000 conversations per day, and 8 GB with 8 cores for up to 20,000. Add at least 1 GB swap, because upgrade dey run second Rails process to apply migrations, and na there small boxes dey run out of memory. 2 GB VPS fit boot and work for two or three agents, but Sidekiq alone fit pass 1 GB under load. So expect containers to get killed with exit code 137 during busy periods and upgrades.
Why password reset emails for Chatwoot never dey arrive?
Because no SMTP settings dey configured. So ActionMailer tries to deliver to localhost on port 25, but no mail server dey inside the container. The job fails for Sidekiq with Errno::ECONNREFUSED: Connection refused - connect(2) for "localhost" port 25 while browser still dey show success message. Set SMTP_ADDRESS, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD and MAILER_SENDER_EMAIL inside .env. Restart the rails and sidekiq services, then monitor docker compose logs -f sidekiq while you trigger reset.
Wetin I need back up to restore Chatwoot?
The Postgres database, the storage_data Docker volume, the .env file, and the compose files. Database alone no be enough, because uploaded files dey inside the volume while Postgres only keeps references to dem. So database-only restore go give you conversations with broken attachments. .env matters because different SECRET_KEY_BASE go sign every user out, and different ACTIVE_RECORD_ENCRYPTION_* keys go make the encrypted columns unreadable.
How I fit upgrade Chatwoot without breaking the database?
Take backup, change the image tag inside 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. Stop the stack first too, because old code against new schema go raise errors. For old install, move one minor version at a time, because migrations dey get removed after dem don fold dem into the base schema.
I fit use the standard postgres image instead of pgvector?
No. Chatwoot schema dey enable the vector extension, so the stock postgres image fails during db:chatwoot_prepare with ERROR: extension "vector" is not available because the extension control file no dey present inside that image. Keep pgvector/pgvector:pg16 from the upstream compose file, or use another image wey ships pgvector for your Postgres major version.