SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Self-Hosted Calendar: CalDAV on a VPS

Sync calendars across phone and laptop without Google. Set up a CalDAV server on your VPS with Radicale, TLS, discovery and client setup that works.

What you are building

A self-hosted calendar is one CalDAV server on a VPS you control, behind TLS, with one login per person. The phone in your pocket and the laptop on your desk show the same events, and so does your partner's laptop. No Google account sits in the middle.

This is a different job from a self-hosted booking page. A booking page is for strangers: it publishes your free slots and lets someone claim one. A calendar server is for your own devices: it stores the events and keeps every client in agreement. People often run both, and the booking tool then reads its availability from the CalDAV server you build here.

The install is small. Radicale is one Python package and about ten lines of config. What decides whether the setup survives the first month is TLS, discovery, per user collections, and backups. Those get most of the space below.

What is CalDAV, and why does it matter?

CalDAV is calendar sync over HTTP. It is defined in RFC 4791 as extensions to WebDAV (web distributed authoring and versioning, a set of extra HTTP methods defined in RFC 4918). A calendar is a collection, which behaves like a directory. One event is one file inside it, written in the iCalendar text format (RFC 5545), the same format as the .ics attachments in your mail.

Clients use ordinary HTTP with a few added methods. PROPFIND asks what is here and what properties it has. REPORT asks for a filtered slice, such as every event in a date range. PUT writes one event and DELETE removes it. Every event carries a UID line, and that identifier is how two devices agree they are looking at the same event rather than a copy.

Portability is the payoff, and it is the whole reason to bother. iOS, macOS, Thunderbird, Evolution and Android through DAVx⁵ all speak CalDAV. Your data is not tied to the server you picked today. Move the files to a different CalDAV server, point the clients at the new hostname, and nothing else changes.

CardDAV rides along. It is the same idea for contacts, defined in RFC 6352 and storing vCard files instead of events. Every server below serves both protocols from the same account, so once the calendar works, the address book is a checkbox.

Which CalDAV server should you run?

Radicale is the smallest thing that works. Python, no database, and the store is a folder of plain files. This guide uses it because a household calendar does not need more, and because there is very little to go wrong at three in the morning.

Baikal is the option with a web admin panel. It runs on PHP and the sabre/dav library, keeps users and calendars in SQLite or MySQL, and lets you add a person in a browser instead of on the command line. Pick it when accounts come and go often.

Nextcloud is right when the calendar is one feature among several. You get calendar, contacts, files and a mobile app, at the cost of PHP-FPM, a database and a background job runner. If that sounds heavy for what you actually need, the lighter Nextcloud alternatives cover the trade, and self-hosted file sync covers the other half of what people install Nextcloud for.

DAViCal is the long-standing PostgreSQL option. It is worth a look only if you already run PostgreSQL and want calendar data living in it.

Install Radicale on Ubuntu 24.04

Radicale 3.5.10 was the current release as of August 2026. Install it into its own virtual environment.

sudo apt update
sudo apt install -y python3-venv apache2-utils nginx
sudo useradd --system --user-group --home-dir / --shell /usr/sbin/nologin radicale
sudo install -d -o radicale -g radicale -m 750 /var/lib/radicale/collections
sudo install -d -m 750 -o root -g radicale /etc/radicale
sudo python3 -m venv /opt/radicale/venv
sudo /opt/radicale/venv/bin/pip install --upgrade radicale

The virtual environment is not a style choice. sudo pip install radicale into the system Python stops with error: externally-managed-environment, because Ubuntu marks its Python as owned by apt so that pip cannot overwrite packaged files.

Write /etc/radicale/config:

[server]
hosts = 127.0.0.1:5232

[auth]
type = htpasswd
htpasswd_filename = /etc/radicale/users
htpasswd_encryption = autodetect

[storage]
filesystem_folder = /var/lib/radicale/collections

hosts binds to loopback on purpose. nginx terminates TLS and forwards to that port, so Radicale never faces the internet directly. The upstream example of 0.0.0.0:5232 publishes an unencrypted service that accepts passwords, which is the one mistake that matters here.

Now the accounts. -5 selects SHA-512 crypt, which Radicale reads with htpasswd_encryption = autodetect and no extra module:

sudo htpasswd -5 -c /etc/radicale/users you
sudo htpasswd -5 /etc/radicale/users partner
sudo chown root:radicale /etc/radicale/users
sudo chmod 640 /etc/radicale/users

-c creates the file and truncates whatever was in it. Use it for the first user only. Running htpasswd -5 -c again months later deletes every account added after the first, and the symptom is one person syncing fine while everyone else gets a password prompt that never ends. Bcrypt works too, and it needs the extra install radicale[bcrypt].

Create /etc/systemd/system/radicale.service, adapted from the unit in the Radicale documentation:

[Unit]
Description=CalDAV and CardDAV server
After=network.target
Requires=network.target

[Service]
ExecStart=/opt/radicale/venv/bin/python -m radicale
Restart=on-failure
User=radicale
UMask=0027
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
NoNewPrivileges=true
ReadWritePaths=/var/lib/radicale/

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now radicale
curl -i http://127.0.0.1:5232/

A healthy result is 401 Unauthorized with a WWW-Authenticate header: the service is listening and authentication is on. Connection refused means it never started, and journalctl -u radicale -n 50 names the option it rejected. ProtectSystem=strict mounts the filesystem read only for this service, so ReadWritePaths=/var/lib/radicale/ is the line that lets it save an event at all. Drop that line and reads keep working while every write fails.

TLS is not optional, because clients refuse plaintext

CalDAV authenticates with HTTP Basic, which sends user:password base64 encoded on every single request. Base64 is an encoding, not encryption. Over plain HTTP you hand the password to every network between the phone and the server, all day, every sync.

The clients enforce this for you. The Radicale documentation notes that macOS Calendar.app may silently refuse to send credentials over unsecured HTTP, and iOS behaves the same way. The account looks configured and simply never syncs, with no error to read.

Point an A record for cal.example.com at the VPS first, because the certificate authority checks it. Then create /etc/nginx/sites-available/cal.example.com:

server {
    listen 80;
    server_name cal.example.com;

    location / {
        proxy_pass        http://localhost:5232/;
        proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header  X-Forwarded-Proto $scheme;
        proxy_set_header  Host $http_host;
        proxy_pass_header Authorization;
    }

    location = /.well-known/caldav  { return 301 https://$host/; }
    location = /.well-known/carddav { return 301 https://$host/; }
}

The four proxy header lines come from the Radicale documentation. Keep them as they are.

sudo ln -s /etc/nginx/sites-available/cal.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d cal.example.com
curl -i -u you https://cal.example.com/

nginx -t prints syntax is ok and test is successful. Reload only after it does, because a reload with a broken file leaves the old config running and hides the mistake until the next restart. Certbot edits the site file in place: it installs the certificate, switches the block to port 443 and adds a redirect from port 80. The final curl prompts for the password and should return 200, which is Radicale's own web interface. A 502 Bad Gateway means nginx is running and Radicale is not listening on 5232.

Why does adding the account fail on a phone?

Because of discovery. RFC 6764 describes how a client turns a hostname into a calendar URL. It looks for a _caldavs._tcp SRV record, then requests https://cal.example.com/.well-known/caldav and expects a redirect to the DAV root. From there it asks for current-user-principal, then for that principal's calendar-home-set, and only then does it see your calendars. A phone gives you one field for the server, so every step has to work unattended.

curl -sI https://cal.example.com/.well-known/caldav

The healthy answer is HTTP/2 301 with a location: https://cal.example.com/ header. A 404 there is why iOS says it cannot verify the account information while Thunderbird on the same network works: Thunderbird uses the full URL you typed, so it never needs the redirect.

The redirect target depends on the server. Radicale served at the root of the site redirects to /. Baikal ships sample rules that redirect to /dav.php with status 308. Nextcloud redirects to /remote.php/dav/.

Create the calendars, and share one with your partner

Many clients cannot create a calendar, only subscribe to one. Open https://cal.example.com/ in a browser, log in as you, and create the calendar there. On disk it lands under /var/lib/radicale/collections/collection-root/you/, with a generated identifier for the folder name.

Radicale's default rights backend is owner_only: an authenticated account reads and writes its own collections under /USERNAME/ and nothing else. For most households that is the correct setting, and the simplest way to share a calendar is a third account. Create household with htpasswd, make the shared calendar under that login, and add it on each device as a second CalDAV account. It works on every client, iOS included, because the calendar sits in that account's own home.

When you want finer control, switch to rule based rights. Add this to /etc/radicale/config:

[rights]
type = from_file
file = /etc/radicale/rights

Then /etc/radicale/rights, based on the example in the Radicale documentation:

[root]
user: .+
collection:
permissions: R

[principal]
user: .+
collection: {user}
permissions: RW

[own-calendars]
user: .+
collection: {user}/[^/]+
permissions: rw

[shared-household]
user: you|partner
collection: you/2f0a9c1e-1f4c-4c2b-9a1b-0d2f7a5c9e11
permissions: rw

The capital letters and the small letters mean different things. R and W read and write collections that are not calendars or address books, which is what a principal folder is. r and w read and write the calendars themselves. Replace that identifier with your calendar's real folder name from the storage path above.

One honest limit: a client that only reads the calendar home set will not display a calendar living under another user's path, because discovery never walks there. Thunderbird and DAVx⁵ can add it by full URL. iOS cannot, which is why the shared account pattern is the one that always works.

Set up the clients, because this is where self-hosted calendars die

iPhone and iPad. Open Settings, then Calendar (filed under Apps on recent iOS versions), then Calendar Accounts, Add Account, Other, Add CalDAV Account. Server is cal.example.com, followed by the user name and password. Description is only a label. If it refuses to save, open the account again: the advanced view exposes Use SSL, the port and the full account URL, and pasting the URL skips discovery entirely.

Android. There is no built-in CalDAV client. Install DAVx⁵ from F-Droid or Google Play, add an account using the base URL https://cal.example.com/ with your user name, then tick the calendars you want. DAVx⁵ writes into the Android calendar provider, so the events appear in whichever calendar app you already use.

Thunderbird. New Calendar, On the Network, then your user name and the location https://cal.example.com/. It lists what it found and asks which calendars to add.

macOS. System Settings, Internet Accounts, Add Other Account, CalDAV, set Account Type to Manual, then the same user name, password and server address.

CalDAV is a polling protocol. There is no push in the specification, so an event you add on the laptop arrives on the phone at the next sync rather than the same second. Set an interval in each client that you can live with, and remember that a shorter interval on a phone costs battery.

Back up the store, which is only files

Under Radicale your calendar is a directory of .ics files, one file per event, plus a small properties file per collection. Anything that copies a directory backs it up, and you can open a backup with less to confirm it holds real events. That is a genuine advantage over a database dump you cannot read.

sudo systemctl stop radicale
sudo tar czf /root/radicale-$(date +%F).tar.gz -C /var/lib/radicale collections
sudo systemctl start radicale

Stop the service for the few seconds the archive takes, so no client is halfway through a write when the files are read. Copy the archive off the box afterwards, because a backup on the same VPS does not survive the failure you are preparing for. Restoring is the reverse: extract, sudo chown -R radicale:radicale /var/lib/radicale/collections, start the service. Every client also holds a local copy of its calendars, so a laptop that has not synced since the failure is a second copy of your data.

When Baikal or Nextcloud is the better fit

Baikal 0.12.1 was released on 5 August 2026 and needs PHP 8.2 or newer. Unpack it outside the web root and expose only its html directory:

sudo apt install -y php-fpm php-sqlite3 php-xml php-mbstring php-curl unzip
cd /tmp
curl -LO https://github.com/sabre-io/Baikal/releases/download/0.12.1/baikal-0.12.1.zip
sudo unzip -q baikal-0.12.1.zip -d /srv
sudo chown -R www-data:www-data /srv/baikal/Specific /srv/baikal/config

Those two directories are the only ones the web server writes to, so nothing else needs to be writable. Inside your nginx server block, the Baikal specific parts are these:

root /srv/baikal/html;
index index.php;

location ~ /(\.ht|Core|Specific|config) { deny all; }

location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}

location = /.well-known/caldav  { return 308 /dav.php; }
location = /.well-known/carddav { return 308 /dav.php; }

Reload nginx, open the site in a browser, and the setup wizard creates the admin account and the SQLite database. Client setup is identical to Radicale's, with https://cal.example.com/ as the server address, because the well-known rule sends discovery to /dav.php.

Nextcloud earns its weight only if you also want files and a phone app in the same login. Its DAV root is /remote.php/dav/, and the same discovery rules apply. For any of these, running the service in a container keeps PHP versions out of your host: Docker Compose on a VPS covers the compose file and the reverse proxy in front of it, and what is worth self-hosting in 2026 is a reasonable place to decide how far down this road you want to go.

Failure modes and the strings you will see

Every sync returns 401. Either the password file lost its accounts to a second htpasswd -c, or the radicale user cannot read it. Check with sudo -u radicale cat /etc/radicale/users; a permission denied there is your answer, and the fix is group radicale with mode 640. Radicale also waits one second after each failed login by default, so a client with a stale password looks slow rather than rejected.

nginx answers 405 on PROPFIND. The URL is being served as a static file, so the WebDAV method never reaches Radicale. Test the endpoint directly:

curl -u you -X PROPFIND -H "Depth: 0" -i https://cal.example.com/you/

A working DAV collection answers 207 Multi-Status. Anything else means the request stopped in the web server.

The phone cannot verify the account, and the browser is happy. Two usual causes. The well-known redirect is missing, tested with the curl above. Or the certificate chain is incomplete, which browsers paper over by fetching the missing intermediate while iOS does not. Check it from the shell:

openssl s_client -connect cal.example.com:443 -servername cal.example.com </dev/null

Look for Verify return code: 0 (ok). If it fails, the nginx config is pointing at cert.pem where it should point at fullchain.pem.

Duplicate events after an import. Each event carries a UID, and clients treat that as identity. Import the same file twice through a tool that regenerates identifiers and you get two events that nothing will ever merge. Delete the extra copies on one device and let the deletion sync out.

It all stops working after a reboot. The service was started by hand. sudo systemctl is-enabled radicale prints disabled, and sudo systemctl enable --now radicale fixes it for good.

FAQ

Do I really need TLS for a self-hosted CalDAV server?

Yes. CalDAV authenticates with HTTP Basic, so the password travels base64 encoded on every request, and base64 is trivially reversible. The clients also enforce it: macOS Calendar.app may silently refuse to send credentials over unsecured HTTP, and iOS behaves the same way, so the account appears to save and then never syncs. sudo certbot --nginx -d cal.example.com is the whole job.

Why does my phone fail to add the account when Thunderbird works?

Thunderbird uses the full URL you typed. A phone gives you one server field, so it follows RFC 6764 discovery: it requests https://cal.example.com/.well-known/caldav and expects a redirect to the DAV root. Without that redirect the phone gets a 404 and reports that it cannot verify the account. Add location = /.well-known/caldav { return 301 https://$host/; } to nginx, then confirm with curl -sI https://cal.example.com/.well-known/caldav that you get a 301 and a location header.

Can two people share one calendar?

Yes, and the reliable way is a shared login. Create a third account with htpasswd, put the shared calendar under it, and add it as a second CalDAV account on each device. Radicale's rights file can instead grant a named user read and write on one collection under another user's path, but a client that only reads its own calendar home set will never display it, so that route suits Thunderbird and DAVx⁵ rather than iOS.

What happens to my events if the VPS dies?

With Radicale the store is plain text: one .ics file per event under /var/lib/radicale/collections/collection-root/, which you can back up with tar and read with less. Restore is extract, chown -R radicale:radicale, start the service. Every synced client also keeps a local copy, so a laptop that was up to date before the failure holds a full second copy of your calendar.

Does a CalDAV server sync my contacts too?

Contacts use CardDAV, a sibling protocol defined in RFC 6352 that stores vCard files instead of events. Radicale, Baikal and Nextcloud all serve it from the same account and the same hostname. On Android, DAVx⁵ syncs calendars and contacts from one account. On iOS you add a second account of type CardDAV with the same credentials, which is why the /.well-known/carddav redirect belongs in your nginx config next to the CalDAV one.

#caldav#calendar#radicale#self-hosting#sync