Running Glacier, a self-hosted chat app, with Docker Compose
← all posts10 Nov 2026
How I containerized a Rust and React chat app into one Compose stack with three backend replicas, a replicated database, healthchecks, and a 154 MB runtime image.

Why I built Glacier
Student organizations and small communities usually run their conversations on a chat platform owned by someone else. Every message and every uploaded file sits on that company's servers, under that company's rules. I wanted to see what it takes to run that kind of app yourself, so I built Glacier, a self-hosted chat app inspired by Discord. It has servers, text channels, voice and video channels with screen sharing, direct messages, and roles and permissions.
I want to work as a cloud or DevOps engineer, and I was curious how Discord delivers messages to so many people in real time. Running Glacier as three backend replicas turned that curiosity into real problems. Sessions have to be readable from every replica, events have to reach users connected to a different process, and rate limits have to hold across all of them. For my Cloud Services midterm at BINUS University, the goal was to run the whole system in containers on one machine with a single command.
Architecture
Every service joins one user-defined bridge network, glacier-net, and reaches the others by service name. The browser opens localhost:8080 and lands on nginx, which serves the built React app and proxies /api and the /gateway WebSocket to three backend replicas. nginx re-resolves the backend name through Docker's DNS every 10 seconds, so docker compose up -d --scale backend=5 works without touching its config.
The backend is Rust, using axum and sqlx with raw SQL. It writes to a MariaDB primary and reads message history from a replica that follows the primary through binlog replication. Redis holds sessions, cache, rate limits, presence, and the pub/sub channel that carries events between replicas. Uploaded files go to MinIO. Prometheus and Grafana watch all of it.
Only three ports reach the host: 8080 for the app, 3001 for Grafana, and 8082 for Adminer. Everything else stays inside the network, and the data lives in six named volumes.
The Dockerfile
Rust release builds are slow, so the backend Dockerfile uses cargo-chef to cache dependencies in their own layer:
FROM rust:1.99.0-slim-bookworm AS chef
RUN cargo install cargo-chef --locked --version 0.1.78
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin glacier
FROM debian:bookworm-20261020-slim AS runtime
# ca-certificates, curl and a non-root user
WORKDIR /app
COPY --from=builder /app/target/release/glacier /usr/local/bin/glacier
ENV BIND_ADDR=0.0.0.0:3000 RUST_LOG=glacier=info,tower_http=info
USER glacier
EXPOSE 3000
CMD ["glacier"]
The planner stage reduces Cargo.toml and Cargo.lock to a recipe, and cargo chef cook compiles every dependency from it. That layer only rebuilds when the dependencies change, so editing the code recompiles just the glacier crate. The runtime stage starts from a clean Debian slim image and copies in one binary. The builder stage weighs 2.68 GB, and the final image is 154 MB.
The frontend follows the same pattern. A Node stage runs npm ci and the Vite build, which comes to 546 MB, and only the dist folder is copied into nginx:1.29.8-alpine, for a 63.7 MB image. Every base image is pinned to a patch version, and CMD uses the exec form so the process runs as PID 1 and receives SIGTERM from docker stop.
docker-compose.yml
backend:
image: ${DOCKERHUB_USER:-akmalfairuz}/glacier-backend:${GLACIER_VERSION:-latest}
build:
context: ./backend
environment:
DATABASE_URL: mysql://${MARIADB_USER}:${MARIADB_PASSWORD}@mariadb-primary:3306/${MARIADB_DATABASE}
DATABASE_READ_URL: mysql://${MARIADB_USER}:${MARIADB_PASSWORD}@mariadb-replica:3306/${MARIADB_DATABASE}
REDIS_URL: redis://redis:6379
deploy:
replicas: 3
depends_on:
mariadb-primary:
condition: service_healthy
redis:
condition: service_healthy
# mariadb-replica and minio as well
networks:
- glacier-net
The backend never uses an IP address or localhost. Inside a container, localhost is the container itself, so it connects to mariadb-primary, redis, and minio by name. MariaDB's healthcheck runs healthcheck.sh --connect --innodb_initialized, which passes only once the server accepts connections. Because the backend waits for service_healthy, its startup migration never runs against a database that isn't ready.
Passwords live in .env, and the compose file reads them with ${MARIADB_PASSWORD:?set MARIADB_PASSWORD in .env}, so a missing value stops Compose with a clear message. Git ignores .env, and the repository ships .env.example instead. Adminer sits under a tools profile, so it only starts when you ask for it.
Run it yourself
git clone https://github.com/AkmalFairuz/glacier.git
cd glacier
cp .env.example .env # change the passwords first
docker compose up -d --build
Open http://localhost:8080 and register an account. To skip the build, pull the published images with GLACIER_VERSION=1.0.0 docker compose pull.


What I learned
The gap between docker compose down and docker compose down -v only felt real once I tested it. I created a server, ran docker compose down, and started the stack again. The containers and the network were gone, but the named volumes stayed, and the same server came back with the same ID. Adding -v deletes those volumes too, so the app starts over with an empty database.

Running three replicas pushed every piece of state out of process memory and into Redis or MariaDB. The glacier:events channel in Redis has three subscribers, one per replica, which is how a message sent through one replica reaches users on another.
Multi-stage builds did most of the work on image size. Going from a 2.68 GB builder to a 154 MB runtime image didn't need any tricks, because the Rust toolchain and its build output never leave the builder stage.
One small trap was in .gitignore. The .env.* pattern also matched .env.example, so the template never reached the repository until I added !.env.example.
Links
- Source code: github.com/AkmalFairuz/glacier
- Video demo: youtube.com/watch?v=7GBVBb07qqQ
- Docker images: akmalfairuz/glacier-backend and akmalfairuz/glacier-nginx