docker-radicale: a CalDAV server in a read-only container that refuses to run as root
Docker image for Radicale calendar and contact server :calendar: + security :closed_lock_with_key: + addons :rocket:
At a glance
- What is it?
- This image wraps the Radicale CalDAV and CardDAV server with a hardened container posture: read-only filesystem, all capabilities dropped except four, a non-root user, a health check, and configuration through environment variables. The trade-off is that you assemble the security options yourself, and a compose file is provided for that.
- Who is it for?
- Adopt docker-radicale if you want to self-host a CalDAV and CardDAV server for a family or a small team and want the container to be unprivileged by default, with LDAP authentication and password hashing already installed.
- Can I use it commercially?
- Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
- Is it still maintained?
- Yes. The repository last received commits 15 days ago.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The security posture is the product, not a checklist
Most Radicale images start the server and stop there. This one leads with the container's own configuration, and the feature list puts it first: the container is read-only with only access to its data directory and without extraneous privileges, and it runs as a normal user rather than root.
The rest of the list is the supporting cast. The image is multi-architecture, for amd64 and arm64, so a Raspberry Pi is a first-class target rather than an afterthought. Radicale is configured through environment variables, with the example given being `RADICALE_CONFIG_SERVER_HOSTS=0.0.0.0:5232`. And the image is described as batteries included, with git and ssh included for versioning, Python ldap3 for LDAP authentication, Python pytz for timezone handling, and Python bcrypt and argon2 for password hashing.
Those four extras are the reason to choose this image over a plain Alpine package of Radicale. LDAP authentication in particular is a separate installation with its own configuration surface, and getting it wrong is a common way to end up with a calendar server that quietly accepts the wrong credentials. Hashing support matters for the same reason, since a CalDAV server holds a password for every user who syncs a calendar.
The image is an enhanced wrapper around the upstream Radicale project, whose repository the labels in the Dockerfile point to, and the Dockerfile records the upstream version as a build argument defaulting to `3.8.3`.
The production command drops every capability and adds back four
The README gives a basic run and then a production one, and the difference is the entire point of the image.
The basic form publishes the port and mounts a data directory:
docker run -d --name radicale \
-p 5232:5232 \
-v ./data:/data \
tomsquest/docker-radicaleThe production form is the one worth reading line by line:
docker run -d --name radicale \
-p 127.0.0.1:5232:5232 \
--init \
--read-only \
--security-opt="no-new-privileges:true" \
--cap-drop ALL \
--cap-add CHOWN \
--cap-add SETUID \
--cap-add SETGID \
--cap-add KILL \
--pids-limit 50 \
--memory 256M \
--health-cmd="curl --fail http://localhost:5232 || exit 1" \
--health-interval=30s \
--health-retries=3 \
-v ./data:/data \
tomsquest/docker-radicaleThe README explains the capabilities, which is the part people usually get wrong. `CHOWN` restores the permissions of the data directory and can be removed if you do not need the chown to run. `SETUID` and `SETGID` are needed to run Radicale as the less privileged `radicale` user with su-exec, and are required. `KILL` allows Radicale to exit, and is required.
Two other details are deliberate. The port is published on `127.0.0.1` rather than on all interfaces, because the container speaks plain HTTP and a reverse proxy is expected in front of it. And the health check hits the server with curl, which is one of the packages installed in the image for exactly that purpose.
Two kinds of tag, and a daily rebuild that is not a version
The image is published in two forms, and the README is careful to explain the difference.
The `latest` tag is based on `alpine:3` with daily automated builds to include the most recent security patches. The version tags, such as `1.2.3.4`, are specific Radicale versions with image updates. The guidance is that you should use `latest` for always-updated security patches and a version tag for stability with a specific Radicale version.
The tag format is `[Radicale version].[Image revision]`, so in `1.2.3.4` the `1.2.3` part is the Radicale version and the final `.4` is the image revision number, incremented for image-specific updates. The current releases show the same pattern: 3.8.3.0 and 3.8.2.0 were both published on 2026-10-08, and 3.8.1.1 on 2026-09-26.
The daily rebuild is a genuine trade rather than a free win. A container that is rebuilt every day is one that changes without a tag changing, so a deployment pinned to `latest` is a rolling change, and the last push on 2026-10-08 with three releases inside two weeks is what a busy release train looks like here. The `CHANGELOG.md` in the repository root is the record of what changed between them.
Configuration through environment variables, applied at container startup
Radicale can be configured in two ways in this image, and the README recommends environment variables for simple configurations.
The mechanism is that environment variables starting with `RADICALE_CONFIG_` automatically update the configuration file at container startup, using the format `RADICALE_CONFIG_SECTION_KEY=value`. The example sets the listening address, the authentication type and the storage folder:
docker run -d --name radicale \
-p 5232:5232 \
-e RADICALE_CONFIG_SERVER_HOSTS=0.0.0.0:5232 \
-e RADICALE_CONFIG_AUTH_TYPE=none \
-e RADICALE_CONFIG_STORAGE_FILESYSTEM_FOLDER=/data/collections \
-v ./data:/data \
tomsquest/docker-radicaleThe rewrite is done by a script in the repository, `update_config_from_env.py`, which the Dockerfile copies into `/usr/local/bin` and which the entrypoint runs before the server starts. That ordering matters: the configuration file that lives in the image is a template, and the environment variables are applied to it on every start, so the mounted configuration and the environment can coexist without you hand-editing a file inside a running container.
The alternative is a custom configuration file, mounted into the `/config` volume, which the Dockerfile copies to `/config/config` and the entrypoint passes with the `--config` argument. The repository has a `config` directory at the root holding the shipped configuration.
The `AUTH_TYPE=none` in the example is the setting to be careful with. It disables authentication entirely, which is appropriate for a local test and wrong for anything reachable from a network.
Data ownership is the part that will need fixing on the host
The image declares two volumes, `/config` and `/data`, and exposes port 5232. The Dockerfile creates a `radicale` group and user with a build UID and GID that default to 2999, creates the two directories, sets the data directory to mode 770 and changes its owner to the radicale user, then removes the root cache.
The entrypoint runs as root long enough to adjust the ownership of the data directory and then drops privileges to the `radicale` user with su-exec, which is why the container needs the `SETUID`, `SETGID` and `CHOWN` capabilities even in the hardened run command.
This is the classic bind mount problem. A directory created on the host by Docker is owned by root, and the container's user is 2999, so the container either fixes the ownership at startup with the `CHOWN` capability or needs a matching owner on the host. The README devotes a section to it, with four options: do nothing and let the container fix the permission itself, create a user and group with id 2999 on the host, force the user and group ids on `docker run`, or build the image with a custom user and group. The Dockerfile supports the last two through the `BUILD_UID`, `BUILD_GID` and `TAKE_FILE_OWNERSHIP` build arguments and environment variables, the last of which defaults to `true` and can be turned off if you manage ownership yourself.
The README also has a section on volumes versus bind mounts, and the `CHOWN` capability note points at it, which is the place to look before choosing a storage approach.
The image is tested by building it and running it
The repository treats the container as the testable artefact, which is the right way to test a Docker image. There are four test modules at the root: `test_image_custom_build.py`, `test_image_envvar.py`, `test_image_prod.py` and `test_update_config_from_env.py`.
The development tooling in `pyproject.toml` explains how they run. The project requires Python 3.13 or above, and the dev dependency group holds pre-commit, pytest, pytest-testinfra, ruff, and the lock file is `uv.lock`. pytest-testinfra is the key one, since it is a pytest plugin that runs tests against a running Docker container, so the assertions are made about the actual image rather than about a mock of it. The presence of a dedicated test for the environment variable rewriting, and of one for the production run, means the two documented deployment shapes are the two that get exercised.
The rest of the repository is the usual supporting cast for a maintained image: a `.pre-commit-config.yaml`, a `docker-compose.yml`, a `docker-entrypoint.sh`, a `Dockerfile`, a `CHANGELOG.md` and a `LICENSE` file, with the licence being GPL-3.0.
The compose file is worth a look, because it is the same hardening as the long `docker run` command expressed as a service, with `init: true`, `read_only: true`, `no-new-privileges:true`, the capability list, a memory limit of 256M and a pids limit of 50, the health check, `restart: unless-stopped` and the data volume. A comment at the top of the file points to the Docker documentation on adapting a compose file for production.
The deployment surface extends past docker run
The table of contents in the README is longer than the container command, and the extra sections cover the situations a self-hosted calendar server actually runs into.
There is a section on running behind Caddy, which is where TLS termination belongs, given that the container itself speaks plain HTTP and the production command publishes only on the loopback interface. There is a section on running with Podman, for hosts that are not running Docker. There is a section on Unraid, for the NAS operating system. There is a section on extending the image, with a birthday calendar given as the worked example, and a section on versioning the configuration with Git, which is what the git and ssh packages in the image are for.
The authentication section points at the Radicale wiki page for LDAP authentication, and the image ships ldap3 to support it. The bcrypt and argon2 packages are there so that the htpasswd style credential store can use modern hashing rather than something weaker.
On the project side, the README documents how to contribute, how to release, and how to update Radicale, including an automated path for updating the upstream version and a separate path for releasing a change to the image itself. That split is the same idea as the tag format: the Radicale version and the image revision move independently.
Where docker-radicale is the wrong tool
The first wrong case is a deployment that expects the container to serve TLS. It does not. The production command binds the port to `127.0.0.1` and the documented path places a reverse proxy in front. If your deployment has no proxy layer, this image is not the answer as it stands, and putting a CalDAV server on the public internet without TLS is not a trade-off worth making for the convenience of a single container.
The second is a configuration that the environment variable scheme cannot express. The `RADICALE_CONFIG_SECTION_KEY` format maps cleanly onto simple settings, and the README itself recommends it for simple configurations. Anything with nested or repeated structure, such as per-user rights or a rights backend, belongs in a configuration file, and you need the file option as well.
The third is a team that wants the host to manage the data volume as a named Docker volume. The whole permission story is built around the bind mount case and the `CHOWN` capability, and the README's own note on volumes versus bind mounts exists because the two behave differently here. If you use a named volume, the ownership handling in the entrypoint is doing work you may not need.
The fourth is a container orchestrator that expects a standard base image with a documented user. The image is Alpine based, runs as a user with UID 2999, and exposes its configuration through a bespoke entrypoint that rewrites a config file on startup. That is all fine for Docker Compose, and less comfortable for platforms that build their own security context around an image.
Editorial conclusion
Adopt docker-radicale if you want to self-host a CalDAV and CardDAV server for a family or a small team and want the container to be unprivileged by default, with LDAP authentication and password hashing already installed. Do not adopt it if you need an image that terminates TLS itself, since the documented deployment path puts a reverse proxy such as Caddy in front, or if you need a single process with no configuration files to hand, because the environment variable scheme only covers simple settings. Before you deploy, choose between the `latest` tag that is rebuilt daily for security patches and a pinned version tag, and set the data directory ownership to the UID and GID the container expects, which default to 2999.
Frequently asked questions
How do I run the Radicale CalDAV server with Docker?
The basic form is `docker run -d --name radicale -p 5232:5232 -v ./data:/data tomsquest/docker-radicale`. The README also gives a production form with a read-only filesystem, all capabilities dropped except CHOWN, SETUID, SETGID and KILL, a pids limit, a memory limit, a health check and the port published on 127.0.0.1 only.
How do I configure Radicale without editing a config file?
Environment variables starting with `RADICALE_CONFIG_` are applied to the configuration file at container startup, using the format `RADICALE_CONFIG_SECTION_KEY=value`, for example `RADICALE_CONFIG_SERVER_HOSTS=0.0.0.0:5232`.
What is the difference between the latest tag and the version tags?
The `latest` tag is based on alpine:3 with daily automated builds so it includes the most recent security patches, while a version tag such as 1.2.3.4 pins a specific Radicale version, where the final number is the image revision for image-specific updates.
Which CPU architectures does the docker-radicale image support?
The image is built for amd64, for a usual server, and arm64, for a Raspberry Pi. Docker selects the correct architecture automatically when you run the image.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/tomsquest-docker-radicale)Was this useful?