Self-hosting

How to self-host team chat with Docker

A practical walkthrough for running your own chat server with Docker Compose: provisioning, config, TLS, backups, and the parts that go wrong.

· 4 min read

The demo of self-hosting takes three commands and about two minutes. The real version takes an afternoon, because between “it runs on localhost” and “my team uses this daily” there are four things — a domain, TLS, backups and a plan for upgrades — that nobody puts in the quickstart.

This walks through both. Examples use Fluid Chat, but the shape applies to any Docker-based chat platform.

What to provision

Start smaller than you think. Team chat is a low-throughput workload — a busy 50-person team generates a few thousand messages a day, which is a rounding error for Postgres.

  • Up to ~25 people: 1 vCPU, 2 GB RAM, 25 GB disk. A cheap VPS genuinely does this.
  • 25–100 people: 2 vCPU, 4 GB RAM, and consider managed Postgres so backups are somebody else's routine.
  • A domain name you control, pointed at the box.
  • Object storage for uploads — any S3-compatible service, or MinIO on the same machine.
  • Docker and Docker Compose installed. That's the only host software you need.

Step 1: Clone and start

The two-minute version
git clone https://github.com/azianmike/fluid-chat.gitcd fluid-chatcp .env.example .envdocker compose up  app       ready on http://localhost:3000  realtime  websockets on :3001  worker    jobs running  postgres  migrations applied

Four things start: the app, a realtime server, a worker for scheduled jobs, and Postgres. Migrations run automatically. Open the app, create the first account — it becomes the workspace owner, with #general and #random already there.

At this point you have a working chat server. It's on localhost, it's HTTP, and it will lose everything if the box dies — so don't invite anyone yet.

Step 2: Configure the environment

Open .env. The defaults are set up for a local run; a few need real values before anyone else touches it.

  1. 1Secrets. Generate fresh values for anything the file marks as a secret. Do not ship the example values — they're examples.
  2. 2Database URL. Point at the bundled Postgres, or at your managed instance if you'd rather someone else handled backups.
  3. 3Public URL. The app needs to know its own address to build correct links and permalinks. Getting this wrong produces links that work for you and nobody else.
  4. 4Object storage. S3-compatible credentials and bucket for uploads and exports.
  5. 5SMTP, if you want invitation and notification email. Optional — you can invite people by link instead.

The repo's docs/env.md covers every variable. Read it once now rather than three times later.

Step 3: TLS and a domain

This is where most self-hosting afternoons go, and it's worth being deliberate. Put a reverse proxy in front — Caddy, nginx or Traefik — terminate TLS there, and route to the app. Two things trip people up:

  • Websockets need explicit proxy configuration. Upgrade headers must be forwarded or realtime silently fails while everything else looks fine. This is the single most common self-hosting bug in chat software, and the symptom — “messages appear when I refresh” — is easy to misdiagnose as a performance problem.
  • The public URL must match the domain, or permalinks and invitations will point somewhere useless.

Caddy is the fastest route if you don't already have opinions: it handles certificates automatically and its websocket defaults are sensible. The repo has an HTTPS and domains doc with the specifics.

Step 4: Backups, before you invite anyone

Backups are the difference between self-hosting and gambling. Fluid keeps everything in one Postgres plus your object store, so this stays simple:

The shape of it
# nightly, to somewhere that is not this serverdocker compose exec -T postgres pg_dump -U fluid fluid | gzip > backup.sql.gz# then sync your object storage bucket as well# then — and this is the step everyone skips —# restore it into a scratch container and log in
  1. 1Dump Postgres on a schedule. Nightly is fine for chat.
  2. 2Ship the dump off the box. A backup on the same disk is not a backup.
  3. 3Sync the object store too, or your archive will have messages referencing files that don't exist.
  4. 4Restore once, on purpose. Spin up a scratch instance from the backup and log in. This hour is the highest-value hour in the whole deployment.

Step 5: Upgrades and maintenance

The ongoing cost is roughly an hour a month, and it's mostly this:

  • Pull and restart to upgrade — migrations run on start. Take a backup first, every time, no exceptions.
  • Patch the host. Unattended upgrades for security patches is the low-effort answer.
  • Watch disk. Uploads accumulate quietly. Set an alert at 80%; disk-full is a particularly annoying way to discover a problem.
  • Check the logs occasionally rather than only when someone complains.

When to add Redis

Redis is optional in Fluid until you run more than one app or realtime process. At that point, realtime events need to fan out across processes via pub/sub instead of a direct HTTP post — otherwise a message sent through process A never reaches someone connected to process B.

One process, no Redis. Scaling out, add Redis. That's the whole rule, and it's a good example of a dependency that earns its place rather than being there from day one.

The things that usually go wrong

SymptomAlmost always
Messages only appear after refreshProxy not forwarding websocket upgrade headers
Invitation links point to localhostPublic URL not set to the real domain
Uploads fail silentlyObject storage credentials, bucket name, or CORS
No email arrivesSMTP not configured — invite by link instead
Everything slow after a few monthsDisk nearly full, or Postgres never vacuumed

Start with the two-minute version.

Get it running on localhost first, then do the domain, TLS and backups properly. Or skip all of it — the hosted version is free too.

FAQ

Questions people actually ask.

How do I self-host team chat with Docker?

Clone the repository, copy .env.example to .env, and run docker compose up. For Fluid Chat that starts the app, a realtime server, a worker and Postgres, with migrations applied automatically. Before inviting anyone, set real secrets and a public URL, put a reverse proxy with TLS in front, and set up backups.

Why do messages only appear after I refresh?

Your reverse proxy isn't forwarding websocket upgrade headers, so realtime updates never arrive while HTTP writes keep working. It's the most common self-hosted chat misconfiguration. Check the websocket section of your proxy config — Caddy handles it by default, nginx and Traefik need explicit directives.

Do I need Redis to self-host Fluid Chat?

Only when you run more than one app or realtime process. With a single process, realtime events are delivered directly. Once you scale out, Redis pub/sub fans events across processes so a message sent through one is delivered to clients connected to another.

How do I back up a self-hosted chat server?

For a Postgres-backed platform like Fluid Chat: dump the database on a schedule, sync your object storage bucket, ship both off the server, and restore into a scratch instance once to confirm it works. A backup that has never been restored isn't a backup yet.

How much maintenance does a self-hosted chat server need?

Roughly an hour a month for a small deployment: pull and restart to upgrade (taking a backup first, since migrations run on start), patch the host, and keep an eye on disk usage as uploads accumulate.

Something not covered? Open an issue.

Your chat server, running before dinner.

Three commands to localhost, an afternoon to production. Or use the free hosted version and let someone else own the certificate renewal.