SimpleX Chat in a Distroless Docker Image: socat Bridges the localhost-Only Server
You have probably run into this if you tried to containerize the SimpleX Chat CLI: the chat server it starts with -p or --chat-server-port binds to 127.0.0.1 and nothing else. There is no --chat-server-host option yet, the feature request on GitHub (issue #6449) is still open, so every other container on the bridge network, and every remote client, gets connection refused on anything except loopback.
This post is the sysadmin's hands-on answer: a multi-stage Dockerfile that produces a scratch-based, rootless image containing only the simplex-chat binary, the exact shared libraries it links against, CA certificates, and a tiny socat bridge that forwards the localhost-only chat server to 0.0.0.0. No shell tools, no package manager, no distro cruft in the final image.
We touched the same distroless idea before in the SvelteKit + Prisma + Bun Dockerfile post, where we talked about what a distroless image is and why a non-root user matters. This post takes it one step further: we build from scratch ourselves instead of pulling a distroless base, and we solve a networking problem that the upstream project has not solved yet.
The short version
- SimpleX Chat's chat server is localhost-only by design, for now. The CLI starts a chat server on 127.0.0.1 when you pass
-por--chat-server-port, and there is no option to bind another interface. GitHub issue #6449 requests exactly that and is still open. - socat is the bridge. A one-line socat listener on 0.0.0.0 forwards every connection to 127.0.0.1 where simplex-chat is actually listening. Simple, stateless, and tiny enough for a scratch image.
- The final image is scratch-based and rootless. It contains the binary, its shared libraries, the ELF interpreter, CA certs, socat, and dash as /bin/sh for the entrypoint script. Nothing else.
- Build once, run anywhere with a pinned release tag. Pass
SIMPLEX_VERSIONas a build arg (v7.0.0 is current at the time of writing) and Docker picks the right prebuilt asset for the target architecture.
- simplex-chat
v7.0.0 (2026-07-28) - Build base
ubuntu:24.04 - Final base
scratch - Bridge
socat from Ubuntu 24.04
Checked 2026-08-18 against the simplex-chat GitHub releases API (v7.0.0 latest stable), issue #6449 (chat server binds localhost only; --chat-server-host requested, still open), and the release asset naming pattern simplex-chat-ubuntu-24_04-<arch>. Release tags and asset names move; re-check before you build.
Why the chat server cannot bind 0.0.0.0 yet
The simplex-chat terminal CLI doubles as a small chat server so you can connect a mobile app to the same chat database. You start it with simplex-chat -p 5226 and it listens on 127.0.0.1:5226, the localhost-only address, no matter what. The upstream code has no host option: issue #6449 ("simplex-chat cli should accept host:port for websocket server") asks for a --chat-server-host flag, and the maintainers have not shipped it.
That is actually a defensible default for a single-user chat client: the CLI's chat server is not meant to be a public network service, so binding loopback is the safe choice.
It only becomes a problem when you containerize, because a container's localhost is its own network namespace. Your other containers, your host, and any remote client cannot reach 127.0.0.1 inside the simplex container.
The fix that does not require touching upstream code: run a tiny forwarding proxy in the same container. socat takes one line to listen on all interfaces and forward to loopback:
socat "TCP-LISTEN:${LISTEN_PORT},bind=0.0.0.0,fork,reuseaddr" "TCP:127.0.0.1:${LOCAL_PORT}"That is the whole trick. Everything else in this post is about making the image around that line as small and as boring as possible.
What we are building
The image is a classic two-stage build. The first stage is a normal Ubuntu 24.04 image where we download the prebuilt simplex-chat binary and copy out the runtime pieces we actually need. The second stage starts from scratch, so the final image has no distribution, no package manager, no shell beyond the dash copy we deliberately add, and no tools for an attacker to reach for.
The Dockerfile, exactly as used:
# Downloads the pre-built simplex-chat release binary and the exact shared
# libraries it links against. The final image is scratch-based, rootless and
# contains nothing but the binary, its libs, CA certificates, socat (to bridge
# the 127.0.0.1-only local chat server to 0.0.0.0) and dash as /bin/sh.
FROM ubuntu:24.04 AS build
ARG SIMPLEX_VERSION
ARG TARGETARCH
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
libgmp10 \
libssl3t64 \
socat \
zlib1g \
&& rm -rf /var/lib/apt/lists/*
# Pick the release asset matching the build architecture.
RUN set -eux; \
arch="${TARGETARCH:-}"; \
if [ -z "$arch" ]; then \
case "$(uname -m)" in \
x86_64 | amd64) arch="amd64" ;; \
aarch64 | arm64) arch="arm64" ;; \
esac; \
fi; \
case "$arch" in \
amd64) asset="simplex-chat-ubuntu-24_04-x86_64" ;; \
arm64) asset="simplex-chat-ubuntu-24_04-aarch64" ;; \
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://github.com/simplex-chat/simplex-chat/releases/download/${SIMPLEX_VERSION}/${asset}" \
-o /usr/local/bin/simplex-chat; \
chmod +x /usr/local/bin/simplex-chat
# Collect the runtime: each shared library under its soname into /out/lib, the
# ELF interpreter at the exact path baked into the binary, CA certs, socat and
# dash (as /bin/sh), a non-root user and the empty state dirs.
RUN set -eux; \
mkdir -p /out/usr/local/bin /out/bin /out/lib /out/etc/ssl /out/tmp /out/home/simplex; \
cp /usr/local/bin/simplex-chat /out/usr/local/bin/; \
cp -L /usr/bin/socat /out/usr/local/bin/socat; \
cp -L /bin/sh /out/bin/sh; \
for bin in /usr/local/bin/simplex-chat /usr/bin/socat /bin/sh; do \
ldd "$bin" | awk '/=> \//{print $3}'; \
done | sort -u | while read -r lib; do \
cp -L "$lib" "/out/lib/$(basename "$lib")"; \
done; \
interp="$(ldd /usr/local/bin/simplex-chat | awk '/ld-linux/{print $1}')"; \
mkdir -p "/out$(dirname "$interp")"; \
cp -L "$interp" "/out$interp"; \
cp -r /etc/ssl/certs /out/etc/ssl/certs; \
printf 'simplex:x:1000:1000:simplex:/home/simplex:/sbin/nologin\n' > /out/etc/passwd; \
printf 'root:x:0:0:root:/root:/sbin/nologin\n' >> /out/etc/passwd; \
printf 'simplex:x:1000:\n' > /out/etc/group; \
printf 'root:x:0:\n' >> /out/etc/group; \
printf 'hosts: files dns\n' > /out/etc/nsswitch.conf; \
printf '#!/bin/sh\nset -e\nLISTEN_PORT="${LISTEN_PORT:-5225}"\nLOCAL_PORT="${LOCAL_PORT:-5226}"\nsocat "TCP-LISTEN:${LISTEN_PORT},bind=0.0.0.0,fork,reuseaddr" "TCP:127.0.0.1:${LOCAL_PORT}" &\nexec simplex-chat -p "${LOCAL_PORT}" "$@"\n' \
> /out/usr/local/bin/entrypoint.sh; \
chmod +x /out/usr/local/bin/entrypoint.sh; \
chown -R 1000:1000 /out/home/simplex /out/tmp
FROM scratch
COPY --from=build /out/ /
USER 1000:1000
ENV HOME=/home/simplex \
PATH=/usr/local/bin \
LD_LIBRARY_PATH=/lib \
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
LISTEN_PORT=5225 \
LOCAL_PORT=5226
WORKDIR /home/simplex
VOLUME ["/home/simplex/.simplex"]
EXPOSE 5225
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD socat /dev/null TCP:127.0.0.1:${LISTEN_PORT},connect-timeout=3 >/dev/null 2>&1 || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]How the build works, stage by stage
The first stage is a normal Ubuntu 24.04 image. It installs exactly what we need to download and run the binary (curl, CA certs, and the shared libraries the prebuilt binary expects, including libgmp10, libssl3t64 and zlib1g) plus socat and dash so we can carry them into the final image.
The download step is where the architecture logic lives. Docker sets TARGETARCH during buildkit cross-platform builds, so docker build --platform linux/amd64 automatically downloads simplex-chat-ubuntu-24_04-x86_64 and --platform linux/arm64 downloads simplex-chat-ubuntu-24_04-aarch64. If TARGETARCH is empty (a plain local build), it falls back to uname -m.
The collection step is the interesting one. A scratch image contains nothing, so we must copy in every dynamic dependency manually. The loop runs ldd on the binary, socat, and dash, deduplicates the resolved library paths, and copies each one into /out/lib. We also copy the ELF interpreter (the ld-linux loader) to the exact path baked into the binary, because glibc binaries hard-code that path. Missing either the libs or the interpreter and the container fails with a confusing error at startup instead of a clear message at build time.
The nsswitch.conf with hosts: files dns is a small but critical file: without it, getaddrinfo in the binary may fail to resolve hostnames, which breaks connecting to SMP servers. CA certs are copied because simplex-chat talks TLS to SMP servers, and SSL_CERT_FILE points the binary at them.
The entrypoint script is the payload of this whole exercise:
#!/bin/sh
set -e
LISTEN_PORT="${LISTEN_PORT:-5225}"
LOCAL_PORT="${LOCAL_PORT:-5226}"
socat "TCP-LISTEN:${LISTEN_PORT},bind=0.0.0.0,fork,reuseaddr" "TCP:127.0.0.1:${LOCAL_PORT}" &
exec simplex-chat -p "${LOCAL_PORT}" "$@"It starts socat in the background on 0.0.0.0:5225, then execs simplex-chat with the local chat server on 127.0.0.1:5226. fork means socat handles many concurrent connections, reuseaddr lets the container restart quickly without a TIME_WAIT bind error, and exec keeps simplex-chat as PID 1 so Docker signals and logs behave normally.
Why scratch beats a slim base here
A slim base like debian:bookworm-slim would work fine and save the ldd chore, so why scratch? Two reasons, both sysadmin-sized.
First, attack surface. The final image has exactly three executables: simplex-chat, socat, and dash. There is no shell that a compromised process can use to browse the filesystem, no package manager to fetch more tools, no compiler, no wget or curl. A service that exposes a network port is exactly where you want the smallest possible blast radius, and the reverse-proxy pattern we covered in the Docker vs Podman comparison applies here too: it is not the only layer, but it is a cheap one.
Second, the image is honest about what it contains. There is no bash waiting to surprise you, no /tmp sprawl, no distro update cadence to babysit. What you see is what runs.
Build and run
Build with a pinned release tag, exactly the discipline from our release tracking workflow. v7.0.0 is the current stable at the time of writing, but check the releases page before you build:
$ docker build --build-arg SIMPLEX_VERSION=v7.0.0 -t simplex-chat:7.0.0 .
# keep the chat database on the host so it survives container rebuilds
$ docker volume create simplex-data
$ docker run -d \
--name simplex-chat \
-p 5225:5225 \
-v simplex-data:/home/simplex/.simplex \
simplex-chat:7.0.0
# watch it come up and confirm the healthcheck passes
$ docker logs -f simplex-chat
$ docker ps --filter name=simplex-chat
# point your phone at ws://<host>:5225 and pair with the CLI
$ docker exec -it simplex-chat /bin/sh$ docker exec simplex-chat sh -c 'socat /dev/null TCP:127.0.0.1:5225,connect-timeout=2 && echo reachable'
reachable
# from another container on the same network
$ docker run --rm --network host alpine sh -c 'echo | nc -w2 127.0.0.1 5225 && echo host-side-reachable'A few notes from running it. The volume must mount the chat database directory, because SimpleX has no account on a server to restore from, and our earlier SimpleX vs WhatsApp vs Telegram post makes the point bluntly: no server-side restore exists, your database is the identity, so back it up like a password vault. The entrypoint passes extra arguments through, so docker run ... simplex-chat:7.0.0 -d /home/simplex/.simplex style overrides still work if you ever need them.
The healthcheck matters on scratch because there is no init inside: socat /dev/null TCP:127.0.0.1:${LISTEN_PORT},connect-timeout=3 opens a throwaway connection to the public side and exits nonzero if the bridge or the server behind it is not accepting, which gives Docker an honest healthy/unhealthy signal instead of guessing from the process state.
The honest trade-offs
The socat bridge binds 0.0.0.0 inside the container, which means the port is reachable by anything that can reach the container. That is the point when you want to expose the chat server to other containers or to a reverse proxy, but it also means you should not publish the container port to the whole internet without thinking.
Other things to weigh. The prebuilt binary is linked against Ubuntu 24.04's glibc, so the build stage must stay on Ubuntu 24.04 for the library set to match. The image only ships dash as /bin/sh, so scripts that assume bash will not run here. And there is no auto-update: when a new simplex-chat release lands, you rebuild with the new tag, which is exactly the deliberate-update posture we recommend for every self-hosted service in the Docker vs Podman comparison.
Is this overkill versus just running simplex-chat under a systemd service on the host? For a single machine where only localhost clients connect, yes, a plain systemd unit is simpler, and nothing here argues otherwise. The image earns its keep when you want the chat server reachable from other containers, when you need a reproducible build for a fleet, or when you want the whole thing to be replaceable by docker run on any host without touching the base system. That is the same trade we laid out in the Compose vs Kubernetes post: choose the tool whose failure mode you would rather debug, and keep the deployment boring.
Official sources
- simplex-chat issue #6449 (chat server binds localhost only, host option requested): https://github.com/simplex-chat/simplex-chat/issues/6449
- simplex-chat releases: https://github.com/simplex-chat/simplex-chat/releases
- SimpleX CLI docs: https://simplex.chat/docs/cli.html
- SimpleX self-hosting docs: https://simplex.chat/docs/chat-relay.html
- Our earlier SimpleX post: https://systhoughts.com/posts/simplex-chat-vs-whatsapp-vs-telegram
- Our earlier distroless-adjacent Dockerfile post: https://systhoughts.com/posts/how-to-dockerize-a-sveltekit-app-with-prisma-and-bun
Are you running the SimpleX CLI in a container, and how are you getting around the localhost-only chat server today? A socat bridge like this, a reverse proxy, or a plain host service? Drop it in the comments.
Until next time, keep your systems thoughtful.

No comments yet