2026-01-28 | #self-hosting
I host more than half a dozen services, and I keep adding more. The latest one in the family is Radicale, which allows one to host calendars and contact lists. Like most people, I've been relying on Google for this, but I've grown increasingly frustrated by the Google calendar integration in Thunderbird. I could spend more time trying to get around it, but when it comes to personal tech, I'm biased towards the more fun and independent option.
The official Radicale v3 docs are quite good, but my specific set-up was not covered: running a radicale docker container alongside an existing containerized reverse proxy (in my case Caddy). If this is similar to your set-up, hope you find this post useful.
I'm assuming you already have a server with docker installed and (optionally) a domain name that you can use for this project. If not, then grab yourself a VPS and a domain name before you start. Setting these up is a whole other tutorial, so I'll skip this part.
The very first step is to create the following directories in your server:
mkdir -p ~/radicale/config
mkdir -p ~/radicale/data
It should go without saying, but storing passwords in plain-text is a bad idea, so you want to store your radicale login password using hashing. This can be achieved with htpasswd:
htpasswd -B -c ~/radicale/config/users your_username
You can run the command again to add more users. The hash of each password will be stored in the ~/radicale/config/users file. Then, add the following lines to the ~/radicale/config/config file to set-up the storage location and the location of the hashed user passwords:
[auth]
type = htpasswd
htpasswd_filename = /etc/radicale/users
htpasswd_encryption = bcrypt
[storage]
filesystem_folder = /var/lib/radicale/collections
When it comes to setting up the docker image, the official documentation provides instructions on how to accomplish this with docker compose. At the time of writing, the file looks like this:
name: Radicale
services:
radicale:
image: ghcr.io/kozea/radicale:stable
ports:
- 5232:5232
volumes:
- config:/etc/radicale
- data:/var/lib/radicale
volumes:
config:
name: radicale-config
driver: local
driver_opts:
type: none
o: bind
device: ./config
data:
name: radicale-data
driver: local
driver_opts:
type: none
o: bind
device: ./data
I am not a fan of how verbose it is. The config also does not specify the user, which means it will run as root by default (a potential security risk). So, I made a few modifications:
name: radicale
services:
radicale:
container_name: radicale
image: ghcr.io/kozea/radicale:stable
mem_limit: 500m # should not need this much memory, adjust to your needs
user: "1000:1000" # do not use root
restart: always # restart container if the server reboots
networks:
- caddy-network # connect the container to the caddy bridge network
volumes:
- ${HOME}/config:/etc/radicale:ro # read-only
- ${HOME}/data:/var/lib/radicale
networks:
caddy-network:
external: true # This network is already defined somewhere else
This version also sets the config/ directory to read-only, and links this container to the the same network used by caddy, which is necessary for the reverse proxy to redirect requests to the docker container (more on that later). Note the last three lines in the config file, these instruct docker compose to look up and use the existing caddy-network instead of creating a new one, and won't delete it when you run docker compose down.
If you launch the container now, you should get an error, as the caddy-network has not been created yet. Even if you didn't get an error, the container is quite useless, as it does not have a way to communicate with the outside world. So, the next step is to set-up the reverse proxy, which will forward outside requests to radicale.
When setting up a web-server / reverse proxy, nginx is usually the default recommendation. I've always found the configuration not very user-friendly, so I've switched to Caddy. For a personal project like this, I really can't see the speed advantages of nginx outweighing the simplicity of Caddy.
Start by creating a file on the server's home directory called ~/Caddyfile, and add the following lines:
calendar.yourdomain.net {
encode gzip zstd
reverse_proxy radicale:5232
}
That's all the configuration you need.
The first line inside the curly brackets enables compression in the responses, reducing the bandwidth. Since contact files (vCards) and calendar files (iCalendar) are just plain-text with lots of regularity, they should compress quite well. The second line forwards requests (when they try to access from https://calendar.yourdomain.net) to the radicale docker container on port 5232 (the one used by default).
Create a new Caddy's docker compose file:
services:
caddy:
image: caddy:latest
container_name: caddy
user: "1000:1000"
read_only: true
restart: always
mem_limit: 500m
logging:
driver: json-file
options:
max-size: "10m"
max-file: "1"
networks:
- caddy-network
ports:
- "80:80"
- "443:443"
volumes:
- ${HOME}/Caddyfile:/etc/caddy/Caddyfile:ro
- ${HOME}/caddy_data:/data
- ${HOME}/caddy_config:/config
networks:
caddy-network:
name: caddy-network
The last 3 lines create the caddy-network, which will allow caddy to communicate with other docker containers. You can now cd into each directory containing the docker-compose.yaml files for both caddy and radicale, and run:
docker compose up -d
After both containers start, the directory structure of the radicale directory should look like this:
~/radicale/
├── config
│ ├── config
│ └── users
├── data
│ └── collections
└── docker-compose.yaml
Open your browser and check the domain defined in the Caddyfile, you should be greeted with the radicale log-in screen. Use the user name and password defined on the first step.
If you've made it this far, I'm assuming you are already using a calendar and would like to migrate it to your new radicale instance. All you need to do is to export your current events to an *.ics file.
Once you have the file, upload it to radicale and it will create a new calendar.
Syncing with email clients like Thunderbird is pretty straightforward. However, on Android phones, you need to install two different apps: one to actually sync with the server, and a calendar app to display the events. For the former, the general consensus is to use DAVx5, as it allows you to sync not only with Radicale instances, but with other servers as well (e.g., Google Calendar). This enables you can have both your personal radicale-hosted calendar, and other shared calendars. If you want to add a shared Google calendar, follow the first three steps described in the section above, scroll down until you see the option "Secret addess in iCal format", and copy that link to DAVx5.
For the calendar app, I've chosen Fossify calendar. It's lightweight and has some useful features, such as displaying the next seven days instead of the current week. I find displaying events that already passed in the current week a waste of screen space. If it's a Friday, I'd rather see what I have coming up during the weekend and early next week, than to see what I did on Monday.
If you also wish to sync your contacts, you need to set-up a new contact list on the radicale instance, go into your contact list app settings and change the default storage location to DAVx5.
I'm pretty happy with my new calendar set-up and, so far, I'm not missing anything from Google calendar.
The process was pretty straightforward; it took me far longer to write this blogpost than to get the system up and running. As a side note, I set up Caddy with Ansible rather than docker compose, as I use it to manage all my remote machines. I ran into issues that prevented me from converting the radicale docker compose setup to ansible, though I may revisit this and automate it in the future.