The Wake-Up Call That Changed My Data Habits
It started with a Google account suspension notice — not mine, but a colleague’s. He woke up to find years of contacts and calendar events locked behind a “suspicious activity” flag. Google restored it three days later, but three days was enough to make me rethink where my own data lived.
My calendar held server maintenance windows, team deadlines, personal appointments. My contacts had phone numbers I hadn’t backed up anywhere else. All of it sitting on Google’s servers, one algorithm decision away from becoming inaccessible at 2 AM when I actually need it.
That weekend, I set up Radicale — a lightweight CalDAV/CardDAV server — on my homelab. It’s been running in production ever since. Sync across iPhone, Android work phone, and Linux desktop hasn’t missed a beat in over a year. The entire stack uses under 50MB of RAM. Here’s exactly how to replicate it.
Understanding the Protocols Before Touching Any Config
CalDAV is the protocol your phone uses to sync calendars. When you add an event on your iPhone, it sends a CalDAV request to iCloud — or whatever server you configure. CardDAV does the same for contacts. Both are open standards running over HTTPS, supported natively by iOS, Android, macOS, Windows, and virtually every calendar or contacts app worth using.
Radicale is a Python-based CalDAV/CardDAV server that’s intentionally minimal. No web UI, no dashboard, no bloat — just the protocol server doing its job. Fewer moving parts means a smaller attack surface and less to break on a random Tuesday.
The stack we’re building:
- Radicale in Docker with a persistent data volume
- bcrypt-hashed password authentication
- Nginx reverse proxy for HTTPS (clients refuse plain HTTP)
Building the Stack Step by Step
Directory Layout
Create the workspace before touching any Docker commands:
mkdir -p ~/radicale/{data,config}
cd ~/radicale
Radicale Configuration File
Create config/config (no file extension — that’s Radicale’s convention):
[server]
hosts = 0.0.0.0:5232
[auth]
type = htpasswd
htpasswd_filename = /data/users
htpasswd_encryption = bcrypt
[storage]
filesystem_folder = /data/collections
[logging]
level = info
Using htpasswd auth means Radicale reads credentials from a file you control. No OAuth tokens, no external service dependency, no single point of failure outside your own server.
Creating Users
Generate a bcrypt-hashed password without installing extra system packages:
docker run --rm -it python:3.11-slim bash -c \
"pip install bcrypt -q && python3 -c \"import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())\""
Copy the output hash, then create the users file:
echo "yourusername:\$2b\$12\$yourhashedpasswordhere" > ~/radicale/data/users
For multiple users, add one username:hash line per user. The plain-text password is what you’ll enter in your phone or desktop client — not the hash.
Docker Compose File
Create docker-compose.yml inside ~/radicale/:
services:
radicale:
image: tomsquest/docker-radicale:latest
container_name: radicale
restart: unless-stopped
ports:
- "5232:5232"
volumes:
- ./data:/data
- ./config/config:/config/config:ro
environment:
- RADICALE_CONFIG=/config/config
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:5232/.well-known/caldav"]
interval: 30s
timeout: 10s
retries: 3
read_only: true and no-new-privileges aren’t optional security theater. With a network-exposed service, you want the container unable to modify its own filesystem if something goes wrong at 2 AM when you’re not watching.
docker compose up -d
docker compose logs -f radicale
You should see Radicale start and confirm it’s listening on port 5232.
Nginx Reverse Proxy with HTTPS
Clients — especially iOS — refuse to sync over plain HTTP. TLS is required. If you’re already running Nginx Proxy Manager or Caddy in your homelab, just point it at port 5232. Otherwise, here’s a minimal Nginx configuration:
limit_req_zone $binary_remote_addr zone=radicale:10m rate=5r/m;
server {
listen 443 ssl http2;
server_name cal.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/cal.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cal.yourdomain.com/privkey.pem;
location / {
limit_req zone=radicale burst=20 nodelay;
proxy_pass http://127.0.0.1:5232;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Script-Name "";
}
}
Rate limiting matters here. Radicale has no brute-force protection built in, so this Nginx config handles it at the proxy layer before bad requests ever reach your server.
Get a certificate with Certbot:
certbot --nginx -d cal.yourdomain.com
If your homelab is internal-only with no public domain, a self-signed cert works fine. You’ll just need to add it as a trusted cert on each device.
Connecting Your Devices
iOS (iPhone and iPad)
Go to Settings → Calendar → Accounts → Add Account → Other → Add CalDAV Account:
- Server:
https://cal.yourdomain.com - Username: your username
- Password: the plain-text password (not the bcrypt hash)
For contacts: Settings → Contacts → Accounts → Add Account → Other → Add CardDAV Account with the same server URL and credentials. iOS autodiscovers both CalDAV and CardDAV endpoints from the root URL — Radicale handles this correctly without any extra configuration.
Android
Android lacks native CalDAV/CardDAV support (yes, really). Install DAVx⁵ from F-Droid or the Play Store:
- Add account → URL and user name
- Enter
https://cal.yourdomain.comwith your credentials - DAVx⁵ discovers your calendars and address books automatically
- Select which collections to sync and set the interval
DAVx⁵ integrates with the system calendar and contacts apps, so everything looks and behaves like a native account once configured.
macOS and Linux Desktop
On macOS, go to System Settings → Internet Accounts → Add Account → CalDAV. For contacts, open the Contacts app and add a CardDAV account through its preferences. Both use the same server URL.
On Linux, Thunderbird with the CardBook extension handles CardDAV reliably. GNOME Calendar and GNOME Contacts both support online accounts through GNOME Settings if you prefer native apps.
Verify the Sync
Create a test event on your phone, then check the Radicale data directory on the server:
ls ~/radicale/data/collections/collection-root/yourusername/
You’ll see directories for each calendar and address book, with .ics files for events and .vcf files for contacts. If the event appears there, sync is working end-to-end.
Keeping It Running Long Term
Production use taught me a few things the docs don’t cover:
Backups are files, which makes them easy. Radicale stores everything as plain .ics and .vcf files on disk. A simple cron job is enough:
# Add to crontab with: crontab -e
0 3 * * * tar -czf /backup/radicale-$(date +%Y%m%d).tar.gz ~/radicale/data/ && find /backup/ -name 'radicale-*.tar.gz' -mtime +30 -delete
This backs up nightly and automatically prunes backups older than 30 days.
Uptime is a non-issue. The restart: unless-stopped policy in the Compose file handles process crashes automatically. Docker starts the container on system boot. In over a year of production use, the container has restarted exactly once — after a host kernel update — and came back online without intervention within seconds.
Adding new users later is just appending a line to the users file. No container restart required — Radicale reads the file on each authentication attempt.
# Generate hash for new user
docker run --rm python:3.11-slim bash -c \
"pip install bcrypt -q && python3 -c \"import bcrypt; print(bcrypt.hashpw(b'newpassword', bcrypt.gensalt()).decode())\""
# Append to users file
echo "newuser:\$2b\$12\$newhash" >> ~/radicale/data/users
Thirty Minutes of Setup, Years of Ownership
From zero to syncing takes about 30 minutes, assuming Docker is already running and you have a domain pointed at your server. After that, the data is yours — no terms-of-service changes to track, no account suspension risk, no subscription fees.
The part I didn’t expect: native sync feels identical to Google Calendar once it’s running. Events added on the phone show up on the desktop within a few seconds. Contacts sync both ways without touching anything. On iOS and Android it looks like a built-in account — because DAVx⁵ and iOS treat it exactly like one.
The real payoff shows up when something goes wrong for someone else — an account suspended, a data policy changed. You shrug. Your calendar is on a server you control.

