Andras Timar
Containers have become the default way to run Collabora Online, and for a long time our images were built the way many images are: on top of debian:stable-slim, with a shell script as the entrypoint. That works, but it carries a lot of baggage. A full Debian userland means a shell, a package manager, and quite a few of libraries that Collabora Online never actually calls — every one of them a line in a vulnerability scanner’s report and a potential foothold for an attacker.
For the openDesk sovereign-workplace initiative in Germany, that baggage is not acceptable. openDesk’s platform, built around the ZenDiS/openCode hardened distroless base image, ships no shell and no package manager at all, and it forbids the use of mount namespaces (unfortunately, so we cannot use our preferred mechanism is to give the forkit/kit process a new mount namespace and bind-mount the shared read-only system template into each jail). So we set out to rebuild the Collabora Online container on that base — and in doing so, to shrink its attack surface dramatically for everyone.
The problem with a shell entrypoint
The old image booted through start-collabora-online.sh. That script did two genuinely useful things before handing off to coolwsd: it generated a self-signed TLS certificate on first run, and it patched /etc/passwd so the server would run correctly under an arbitrary UID — the way OpenShift and many Kubernetes setups launch containers.
The trouble is that both of those tasks require a shell to be present in the final image, and the UID trick relied on an LD_PRELOAD of libnss_wrapper. A distroless image has none of that. Rather than work around the missing shell, we removed the need for one.
Both jobs moved into coolwsd itself:
-
Certificate generation is now done natively using the OpenSSL 3.0 API (EVP_RSA_gen), gated on the same conditions the script used — SSL enabled, no certificate already present, and the DONT_GEN_SSL_CERT escape hatch honoured.
-
The arbitrary-UID entry is written by appending a cool:x:<uid>:<gid>:… line to /etc/passwd at startup. Because getpwuid() reads the file live, a direct append does the job with no LD_PRELOAD and no re-exec, and it drops the libnss_wrapper runtime dependency entirely.
With those folded in, the shell entrypoint could be deleted, and every one of our Dockerfiles now invokes /usr/bin/coolwsd directly.
Building minimal without building from scratch
The next question was how to produce a tiny rootfs without giving up the convenience of Debian packaging. The answer is a two-stage build.
The builder stage is still debian:stable-slim and still apt installs the Collabora Online packages exactly as before. A new script, assemble-rootfs.sh, then works out precisely which files those packages contributed — by snapshotting the installed-package set before and after the install and diffing it — and copies only those files, their symlinks, and the generated jail template into a clean /rootfs.Documentation, man pages, and build-only tooling are stripped out.
Here the architecture of Collabora Online pays off. The engine links its externals — OpenSSL, libpng, libxml2, fontconfig and the rest — statically, so the running server barely touches the distribution’s native libraries. Once coolwsd’s dependencies are accounted for, the only shared library we actually need to ship alongside the base’s own glibc is libstdc++. The feared “large vulnerable Debian library surface” simply wasn’t there.
The runtime stage then starts FROM the ZenDiS hardened base and does nothing more than COPY –from=builder /rootfs /, set USER 1001, and run coolwsd.
Capabilities without namespaces
Collabora Online isolates each document in a chroot jail, and setting that jail up needs a few Linux capabilities (cap_sys_chroot, cap_chown, cap_fowner, and cap_sys_admin for the mount helper). Normally those come via mount namespaces — but openDesk forbids namespaces, so we fall back to classic file capabilities stamped directly onto the jail binaries.
The nice surprise was that this needs no shell in the final image either. BuildKit — already required by these Dockerfiles — preserves the security.capability extended attribute across a COPY –from, so the capabilities set on the binaries in the builder survive into the distroless runtime image intact. We re-apply them on the staged rootfs to be safe, and that’s it.
A health-check without curl
Removing the shell created one more gap to close. Container orchestrators expect a HEALTHCHECK, and the conventional way to write one is to shell out to curl against the loopback endpoint — but a distroless image has neither a shell nor curl to shell out to. Rather than reintroduce either, we gave coolwsd its own built-in probe: coolwsd –probe. It reads the running server’s own configuration to pick the listening port and the http/https scheme, connects to the loopback endpoint, and exits 0 only when the server answers HTTP 200.
Because it talks to coolwsd’s own socket it follows ssl.enable and correctly ignores ssl.termination (which describes an upstream proxy the probe bypasses), and it connects to the 127.0.0.1 literal so it needs no name resolution at all — with a [::1] fallback for the rare IPv6-only bind. It also silences logging so it neither writes to nor announces itself in the running server’s log, printing a single diagnostic line only on failure. The from-packages image now wires this in as its HEALTHCHECK, so the hardened, shell-less image is fully self-checking with nothing added to it.
Fail closed, not open
A minimal image has a sharp edge: if coolwsd links a library the base doesn’t provide, you don’t find out until the server fails to start in production. We learned this the honest way when libpam — present in Debian’s base but absent from ZenDiS — went missing. (The fix there was to dlopen libpam on demand, since it’s only needed for the optional admin-console PAM authentication, and drop the hard link.)
To make sure that never happens silently again, the rootfs assembly now runs a fail-closed dependency-closure check. It resolves the shared-library needs of both coolwsd and the engine binaries, and if any needed library is neither shipped in the rootfs nor provided by the base, the build fails — not the deployment.
Slimming the jail, too
While we were at it, we took a hard look at what the per-document jail template was carrying. Over the years it had accumulated copies of system data the sandboxed process doesn’t use in this configuration: glibc locale data, system hyphenation dictionaries, liblangtag data, and the system NSS and sqlite libraries. Each of those is now sourced from the target image where it’s genuinely needed, or dropped, keeping the jail — and the image — leaner.
The result
Collabora Online’s from-packages image now builds on a distroless, hardened base with no shell and no package manager, a native-library footprint of essentially glibc plus libstdc++, file capabilities that survive the build cleanly, and a build that refuses to produce an image with a missing dependency. It runs happily under an arbitrary UID for OpenShift-style deployments, and it does all of this while still being assembled from the same Debian packages we already ship and test.
For openDesk it’s a hard requirement met. For everyone else running Collabora Online in a container, it’s simply a smaller thing to attack and a smaller thing to keep patched — which is exactly what a container base should be.
