~/blog/"Self-hosting my personal calendar and contact list"


Table of contents


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.

Server set-up

Configuration and passwords

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

Docker containers

Radicale

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.

Reverse proxy using Caddy

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.

Syncing

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.

  1. Open the google calendar webpage.
  2. On your calendar list, click the 3 vertical dots next to the calendar you want to export.
  3. Select "Settings and sharing"
  4. Click on "Export calendar"

Once you have the file, upload it to radicale and it will create a new calendar.

Android

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.

Contact list syncing

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.

Final thoughts

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.