How to Run CalDAV Calendar on Your VPS Without Google
Sync phone and laptop calendars without Google. Set up Radicale on your VPS with TLS, discovery, per-user calendars, and client setup wey works.
Wetin you dey build
Self-hosted calendar na one CalDAV server for VPS wey you control, behind TLS, with one login for each person. The phone wey dey your pocket and laptop wey dey your desk go show the same events, and your partner laptop too. No Google account dey for middle.
This na different work from self-hosted booking page. Booking page na for strangers: e publishes the free time wey you get and lets person claim one. Calendar server na for your own devices: e 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 wey you build here.
The install small. Radicale na one Python package and about ten lines of config. Wetin go decide whether the setup survive the first month na TLS, discovery, per-user collections, and backups. Na those ones get most of the space below.
Wetin be CalDAV, and why e matter?
CalDAV na calendar sync over HTTP. RFC 4791 define am as extensions to WebDAV (web distributed authoring and versioning, na set of extra HTTP methods wey RFC 4918 define). Calendar na collection, and e dey behave like directory. One event na one file inside am, written for iCalendar text format (RFC 5545), the same format wey the .ics attachments for your mail dey use.
Clients dey use ordinary HTTP with some extra methods. PROPFIND dey ask wetin dey here and which properties e get. REPORT dey ask for filtered part, like every event for one date range. PUT dey write one event, and DELETE dey remove am. Every event get one UID line, and na that identifier two devices dey use to agree say dem dey look the same event, no be copy.
Portability na the main benefit, and na the full reason to use am. iOS, macOS, Thunderbird, Evolution, and Android through DAVx⁵ all speak CalDAV. Your data no dey tied to the server wey you choose today. Move the files go another CalDAV server, point the clients to the new hostname, and nothing else go change.
CardDAV dey work alongside am. Na the same idea for contacts, wey RFC 6352 define, and e dey store vCard files instead of events. Every server below dey serve both protocols from the same account. So once the calendar work, address book na just one checkbox.
Which CalDAV server you suppose run?
Radicale na the smallest option wey work. E use Python, e no need database, and e store data for one folder of plain files. This guide use am because household calendar no need anything more, and because small things fit spoil for 3 a.m.
Baikal na the option wey get web admin panel. E run on PHP and the sabre/dav library, e keep users and calendars for SQLite or MySQL, and e let you add person through browser instead of command line. Choose am when accounts dey change often.
Nextcloud make sense when calendar na just one feature among many. You get calendar, contacts, files, and mobile app, but you go need PHP-FPM, database, and background job runner. If this one too heavy for wetin you really need, the lighter Nextcloud alternatives explain the trade-off, while self-hosted file sync cover the other reason people dey install Nextcloud.
DAViCal na the long-standing PostgreSQL option. E only make sense if you already dey run PostgreSQL and you want calendar data to live inside am.
Ubuntu 24.04 for Radicale Install
August 2026 reach, Radicale 3.5.10 na the current release. Install am inside e 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 radicaleVirtual environment no be style choice. sudo pip install radicale go inside system Python dey stop with error: externally-managed-environment, because Ubuntu mark e Python as package wey apt own, so pip no fit overwrite files wey package install.
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/collectionshosts bind to loopback on purpose. nginx dey terminate TLS and forward request go that port, so Radicale no dey face internet directly. Upstream example of 0.0.0.0:5232 dey publish unencrypted service wey dey accept passwords. Na this be the important mistake for here.
Now make the accounts. -5 select SHA-512 crypt, wey Radicale dey read 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 create the file and truncate anything wey dey inside am. Use am for the first user only. If you run htpasswd -5 -c again after some months, e go delete every account wey you add after the first one. The symptom be say one person dey sync fine, while everybody else dey get password prompt wey no dey finish. Bcrypt still dey work, but e need the extra install radicale[bcrypt].
Create /etc/systemd/system/radicale.service, based on the unit for 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.targetsudo systemctl daemon-reload
sudo systemctl enable --now radicale
curl -i http://127.0.0.1:5232/Healthy result na 401 Unauthorized with WWW-Authenticate header: the service dey listen and authentication dey on. Connection refused mean say e never start, while journalctl -u radicale -n 50 name the option wey e reject. ProtectSystem=strict mount the filesystem as read-only for this service, so ReadWritePaths=/var/lib/radicale/ na the line wey allow am save event at all. If you remove that line, reads go continue to work, but every write go fail.
TLS no be optional, because clients dey refuse plaintext
CalDAV dey authenticate with HTTP Basic, wey dey send user:password base64 encoded for every single request. Base64 na encoding, e no be encryption. For plain HTTP, you dey hand your password give every network wey dey between the phone and server, all day, for every sync.
The clients enforce this for you. Radicale documentation talk say macOS Calendar.app fit silently refuse to send credentials through unsecured HTTP, and iOS dey behave the same way. The account go look configured but e simply no dey sync, and you no go get error to read.
Point an A record for cal.example.com to the VPS first, because certificate authority dey check am. 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 Radicale documentation. Leave dem as dem be.
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 go print syntax is ok and test is successful. Reload only after e do so, because reload with broken file go leave the old config running and hide the mistake until the next restart. Certbot dey edit the site file in place: e install the certificate, switch the block to port 443, and add redirect from port 80. The final curl go prompt for the password and suppose return 200, wey be Radicale own web interface. A 502 Bad Gateway mean say nginx dey run and Radicale no dey listen on 5232.
Phone dey fail when e dey add the account why?
Na discovery cause am. RFC 6764 describe how client dey turn hostname into calendar URL. E dey look for _caldavs._tcp SRV record, then request https://cal.example.com/.well-known/caldav and expect redirect go the DAV root. From there, e ask for current-user-principal, then for that principal's calendar-home-set, and na only then e go see your calendars. Phone dey give you only one field for server, so every step must work without anybody guiding am.
curl -sI https://cal.example.com/.well-known/caldavCorrect response na HTTP/2 301 with location: https://cal.example.com/ header. If 404 dey there, na why iOS dey talk say e no fit verify the account information while Thunderbird for the same network dey work: Thunderbird dey use the full URL wey you type, so e no need the redirect.
The redirect target depend on the server. Radicale wey dey serve from the site root redirect go /. Baikal ship with sample rules wey redirect go /dav.php with status 308. Nextcloud redirect go /remote.php/dav/.
Create calendars, then share one with your partner
Many clients no fit create calendar; dem fit only subscribe to one. Open https://cal.example.com/ for browser, log in as you, then create the calendar there. For disk, e go dey under /var/lib/radicale/collections/collection-root/you/, with generated identifier as the folder name.
Radicale default rights backend na owner_only: authenticated account fit read and write im own collections under /USERNAME/, and nothing else. For most households, na the correct setting be this. The simplest way to share calendar na to use third account. Create household with htpasswd, create the shared calendar under that login, then add am for each device as second CalDAV account. E go work for every client, including iOS, because the calendar dey inside that account own home.
When you need finer control, switch to rule based rights. Add this to /etc/radicale/config:
[rights]
type = from_file
file = /etc/radicale/rightsThen /etc/radicale/rights, based on the example for 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: rwThe capital letters and small letters get different meaning. R and W fit read and write collections wey no be calendars or address books, and principal folder na this kind collection. r and w fit read and write the calendars themselves. Replace that identifier with your calendar real folder name from the storage path above.
One honest limit: client wey only reads the calendar home set no go display calendar wey dey under another user path, because discovery no dey search there. Thunderbird and DAVx⁵ fit add am with full URL. iOS no fit do this, na why the shared account pattern dey work every time.
Set up the clients, na for here self-hosted calendars dey fail
iPhone and iPad. Open Settings, then Calendar (for recent iOS versions, e dey under Apps), then Calendar Accounts, Add Account, Other, Add CalDAV Account. Server na cal.example.com, followed by the user name and password. Description na only label. If e no gree save, open the account again: the advanced view show Use SSL, the port and the complete account URL. If you paste the URL, discovery no go happen.
Android. Android no get built-in CalDAV client. Install DAVx⁵ from F-Droid or Google Play. Add account with the base URL https://cal.example.com/ and your user name, then tick the calendars wey you want. DAVx⁵ dey write into the Android calendar provider, so the events go appear for any calendar app wey you already dey use.
Thunderbird. Select New Calendar, On the Network, then enter your user name and the location https://cal.example.com/. E go list wetin e find and ask which calendars to add.
macOS. Go System Settings, Internet Accounts, Add Other Account, CalDAV. Set Account Type to Manual, then enter the same user name, password and server address.
CalDAV na polling protocol. The specification no get push, so event wey you add for laptop go reach phone for the next sync, instead of the same second. Set interval for each client wey you fit manage, and remember say shorter interval for phone dey use more battery.
Back up the store, na only files
For Radicale, your calendar na directory of .ics files, one file for each event, plus one small properties file for each collection. Anything wey fit copy directory fit back am up, and you fit open backup with less to confirm say e get real events. This na real advantage over database dump wey you no fit read.
sudo systemctl stop radicale
sudo tar czf /root/radicale-$(date +%F).tar.gz -C /var/lib/radicale collections
sudo systemctl start radicaleStop the service for the few seconds wey archive dey take, so no client dey halfway through write when dem dey read the files. Copy the archive comot from the box afterwards, because backup wey dey the same VPS no go survive the failure wey you dey prepare for. To restore, do the reverse: extract, sudo chown -R radicale:radicale /var/lib/radicale/collections, then start the service. Every client still get local copy of its calendars, so laptop wey never sync since the failure na another copy of your data.
When Baikal or Nextcloud better pass
Baikal 0.12.1 release happen for 5 August 2026, and e need PHP 8.2 or newer. Unpack am outside web root, then expose only im 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/configNa those two directories web server dey write to. So nothing else need writable permission. Inside your nginx server block, na these be the Baikal-specific parts:
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 for browser, and setup wizard go create the admin account and SQLite database. Client setup dey the same as Radicale own, with https://cal.example.com/ as server address, because the well-known rule dey send discovery go /dav.php.
Nextcloud only make sense if you also want files and phone app under the same login. Its DAV root na /remote.php/dav/, and the same discovery rules still apply. For any of these services, running am inside container keep PHP versions away from your host: Docker Compose for VPS cover the compose file and reverse proxy wey dey in front of am, while wetin worth self-hosting for 2026 na reasonable place to decide how far you want follow this path.
Failure modes and the strings wey you go see
Every sync dey return 401. Either password file lose its accounts to another htpasswd -c, or the radicale user no fit read am. Check am with sudo -u radicale cat /etc/radicale/users; if permission denied show there, na that be the answer, and the fix na group radicale with mode 640. Radicale also dey wait one second after each failed login by default, so client wey get old password fit look slow instead of rejected.
nginx dey answer 405 for PROPFIND. The URL dey serve as static file, so the WebDAV method no dey reach Radicale. Test the endpoint directly:
curl -u you -X PROPFIND -H "Depth: 0" -i https://cal.example.com/you/Working DAV collection dey answer 207 Multi-Status. Anything else mean say request stop for web server.
The phone no fit verify the account, but browser dey work. Two common causes dey. The well-known redirect fit dey missing; test am with the curl above. Or certificate chain no complete. Browser fit hide this by fetching the missing intermediate, but iOS no dey do that. Check am from the shell:
openssl s_client -connect cal.example.com:443 -servername cal.example.com </dev/nullLook for Verify return code: 0 (ok). If e fail, nginx config dey point to cert.pem when e suppose point to fullchain.pem.
Duplicate events after import. Every event carry one UID, and clients dey use am as identity. If you import the same file twice with tool wey regenerate identifiers, you go get two events wey nothing go ever merge. Delete the extra copies for one device and allow the deletion sync out.
Everything stop to work after reboot. Somebody start the service by hand. sudo systemctl is-enabled radicale go print disabled, and sudo systemctl enable --now radicale go fix am permanently.
FAQ
I really need TLS for self-hosted CalDAV server?
Yes. CalDAV dey authenticate with HTTP Basic, so password dey travel as base64 encoded for every request, and base64 easy to reverse. The clients too dey enforce am: macOS Calendar.app fit silently refuse to send credentials through unsecured HTTP, and iOS dey behave the same way. So account fit look like say e save, but e no go ever sync. sudo certbot --nginx -d cal.example.com na the whole job.
Why my phone no fit add the account when Thunderbird dey work?
Thunderbird dey use the full URL wey you type. Phone dey give you only one server field, so e follow RFC 6764 discovery: e request https://cal.example.com/.well-known/caldav and expect redirect go the DAV root. Without that redirect, phone go get 404 and report say e no fit 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 say you get 301 and a location header.
Two people fit share one calendar?
Yes, and the reliable way na shared login. Create third account with htpasswd, put the shared calendar under am, and add am as second CalDAV account for each device. Radicale rights file fit instead give named user read and write access to one collection under another user's path. But client wey only dey read its own calendar home set no go ever display am. So that method better suit Thunderbird and DAVx⁵ instead of iOS.
Wetin go happen to my events if the VPS crash?
With Radicale, the store na plain text: one .ics file for each event under /var/lib/radicale/collections/collection-root/. You fit back am up with tar and read am with less. To restore, extract, chown -R radicale:radicale, then start the service. Every synced client still keep local copy, so laptop wey dey up to date before the failure go get full second copy of your calendar.
CalDAV server dey sync my contacts too?
Contacts dey use CardDAV, na sibling protocol wey RFC 6352 define. E store vCard files instead of events. Radicale, Baikal and Nextcloud all serve am from the same account and same hostname. For Android, DAVx⁵ sync calendars and contacts from one account. For iOS, you add second account with type CardDAV and the same credentials. Na why the /.well-known/carddav redirect suppose dey your nginx config beside the CalDAV one.