Monitoring Your Self-Hosted Radicale CalDAV/CardDAV Server with Vigilmon
Your Radicale server went down over the weekend. Calendar apps on your team's phones couldn't sync. Appointments got missed. Nobody noticed until Monday morning.
Radicale is a lightweight open-source CalDAV and CardDAV server written in Python. It runs on port 5232 and is responsible for keeping calendar, contact, and task data synchronized across every device your team uses. When it fails, it fails quietly.
By the end of this guide you'll have:
- HTTP availability monitoring on port 5232
- CalDAV calendar sync endpoint monitoring
- CardDAV contacts sync endpoint monitoring
- Authentication backend connectivity checks
- Filesystem storage accessibility verification
- SSL/TLS certificate expiry monitoring
- Slack alerts and a public status page
Why monitoring matters for Radicale
Radicale is deliberately simple — but that simplicity makes it easy to overlook failure modes:
- Process crash — Python crashes leave no HTTP response; calendar apps start silently queuing failed syncs
- Filesystem full — Radicale writes
.icsand.vcffiles to disk; a full filesystem silently rejects writes while reads still work - Authentication backend failure — if you use htpasswd or LDAP, auth failures block all clients but the server stays up
- SSL certificate expiry — clients reject expired certificates, blocking all sync without a clear error message
- Permission changes — the Radicale data directory losing write permission causes silent write failures
A monitoring setup for Radicale needs to check all of these layers, not just "is port 5232 open?"
Step 1: Verify Radicale's built-in HTTP health response
Radicale responds to a GET request at / with a plain text response. Use this as your primary availability check:
curl -v http://localhost:5232/
# Returns: Radicale works!
If Radicale is configured with authentication, add credentials:
curl -v -u username:password http://localhost:5232/
For HTTPS:
curl -v https://radicale.yourdomain.com/
# Returns 200 with "Radicale works!"
Step 2: Add a health check wrapper script
Radicale doesn't expose a structured health endpoint. Create a Python health check script that aggregates all critical checks:
#!/usr/bin/env python3
# radicale-health.py
import json
import os
import subprocess
import ssl
import socket
import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.request
import urllib.error
RADICALE_URL = os.environ.get('RADICALE_URL', 'http://localhost:5232')
RADICALE_USER = os.environ.get('RADICALE_USER', '')
RADICALE_PASS = os.environ.get('RADICALE_PASS', '')
DATA_DIR = os.environ.get('RADICALE_DATA_DIR', '/var/lib/radicale/collections')
TLS_DOMAIN = os.environ.get('RADICALE_DOMAIN', '')
def check_radicale_http():
try:
req = urllib.request.Request(f'{RADICALE_URL}/')
if RADICALE_USER:
import base64
creds = base64.b64encode(f'{RADICALE_USER}:{RADICALE_PASS}'.encode()).decode()
req.add_header('Authorization', f'Basic {creds}')
with urllib.request.urlopen(req, timeout=5) as resp:
return 'up' if resp.status == 200 else 'degraded'
except Exception:
return 'down'
def check_filesystem():
try:
if not os.path.isdir(DATA_DIR):
return 'down'
probe = os.path.join(DATA_DIR, '.health-probe')
with open(probe, 'w') as f:
f.write('ok')
os.unlink(probe)
return 'up'
except Exception:
return 'down'
def check_disk_space():
try:
stat = os.statvfs(DATA_DIR)
free_pct = (stat.f_bavail / stat.f_blocks) * 100
return 'up' if free_pct > 10 else 'degraded'
except Exception:
return 'unknown'
def check_tls_expiry(domain, port=443, warn_days=30):
if not domain:
return 'skip'
try:
ctx = ssl.create_default_context()
with ctx.wrap_socket(socket.socket(), server_hostname=domain) as s:
s.settimeout(5)
s.connect((domain, port))
cert = s.getpeercert()
expiry = datetime.datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
days_left = (expiry - datetime.datetime.utcnow()).days
if days_left < 0:
return 'expired'
if days_left < warn_days:
return f'expiring_in_{days_left}d'
return 'up'
except Exception:
return 'down'
class HealthHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != '/health':
self.send_response(404)
self.end_headers()
return
checks = {
'radicale_http': check_radicale_http(),
'filesystem': check_filesystem(),
'disk_space': check_disk_space(),
'tls_cert': check_tls_expiry(TLS_DOMAIN),
}
healthy = all(v in ('up', 'skip') for v in checks.values())
body = json.dumps({'status': 'ok' if healthy else 'degraded', 'checks': checks})
self.send_response(200 if healthy else 503)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(body.encode())
def log_message(self, fmt, *args):
pass # suppress access logs
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', 5233), HealthHandler)
print('Radicale health on :5233')
server.serve_forever()
Run it as a systemd service:
# /etc/systemd/system/radicale-health.service
[Unit]
Description=Radicale health check server
After=radicale.service
[Service]
ExecStart=/usr/bin/python3 /opt/radicale-health.py
Environment=RADICALE_URL=http://localhost:5232
Environment=RADICALE_USER=healthcheck
Environment=RADICALE_PASS=secret
Environment=RADICALE_DATA_DIR=/var/lib/radicale/collections
Environment=RADICALE_DOMAIN=radicale.yourdomain.com
Restart=always
[Install]
WantedBy=multi-user.target
Enable and start:
systemctl enable radicale-health
systemctl start radicale-health
curl http://localhost:5233/health
Step 3: Monitor the CalDAV calendar sync endpoint
CalDAV clients sync calendars via PROPFIND requests to paths like /username/calendar-name/. Confirm these paths respond correctly:
Sign up at vigilmon.online (free, no card required).
Primary availability monitor:
- Click New Monitor → HTTP
- URL:
https://radicale.yourdomain.com/ - Expected status:
200 - Keyword check:
Radicale works - Check interval: 1 minute
CalDAV principal endpoint:
- New Monitor → HTTP
- URL:
https://radicale.yourdomain.com/.well-known/caldav - Expected status:
301or302(redirect to the user's calendar root) - Check interval: 5 minutes
CardDAV principal endpoint:
- New Monitor → HTTP
- URL:
https://radicale.yourdomain.com/.well-known/carddav - Expected status:
301or302 - Check interval: 5 minutes
Step 4: Monitor the health check wrapper
Composite health monitor:
- New Monitor → HTTP
- URL:
https://radicale.yourdomain.com:5233/health - Expected status:
200 - Keyword check:
"status":"ok" - Check interval: 1 minute
Set a response time alert at 5000ms — slow Radicale responses indicate filesystem I/O issues, often a precursor to write failures.
Step 5: Monitor SSL/TLS certificate expiry
Radicale clients (iOS Calendar, macOS Calendar, Thunderbird) refuse to sync when the TLS certificate is expired or invalid. This is one of the most common Radicale failure modes and one of the easiest to prevent with monitoring.
In Vigilmon:
- New Monitor → SSL Certificate
- Domain:
radicale.yourdomain.com - Alert when: 30 days before expiry
- Alert again: 7 days before expiry
You can also use the TLS check built into the health script above, which reports expiring_in_Xd in the JSON response — use a Vigilmon keyword alert to catch this state.
Step 6: Monitor authentication backend
If you use htpasswd authentication, confirm the htpasswd file is readable:
# Add to radicale-health.py
def check_auth_backend():
htpasswd_file = os.environ.get('RADICALE_HTPASSWD', '/etc/radicale/htpasswd')
if not htpasswd_file:
return 'skip'
try:
with open(htpasswd_file, 'r') as f:
content = f.read()
return 'up' if len(content.strip()) > 0 else 'empty'
except Exception:
return 'down'
For LDAP authentication, add an LDAP bind check:
import ldap3
def check_ldap_auth():
ldap_url = os.environ.get('RADICALE_LDAP_URL', '')
ldap_bind_dn = os.environ.get('RADICALE_LDAP_BIND_DN', '')
ldap_bind_pass = os.environ.get('RADICALE_LDAP_BIND_PASS', '')
if not ldap_url:
return 'skip'
try:
server = ldap3.Server(ldap_url, connect_timeout=3)
conn = ldap3.Connection(server, ldap_bind_dn, ldap_bind_pass, auto_bind=True)
conn.unbind()
return 'up'
except Exception:
return 'down'
Step 7: Heartbeat for iCalendar data integrity
Radicale stores calendar data as .ics files. Corrupt .ics files can cause silent sync failures for specific calendars. Add a nightly integrity check:
#!/usr/bin/env python3
# check-ical-integrity.py
import os
import glob
import urllib.request
DATA_DIR = '/var/lib/radicale/collections'
HEARTBEAT_URL = os.environ.get('VIGILMON_ICAL_HEARTBEAT', '')
errors = []
for ics_file in glob.glob(f'{DATA_DIR}/**/*.ics', recursive=True):
try:
with open(ics_file, 'r', encoding='utf-8') as f:
content = f.read()
if 'BEGIN:VCALENDAR' not in content or 'END:VCALENDAR' not in content:
errors.append(ics_file)
except Exception as e:
errors.append(f'{ics_file}: {e}')
if errors:
print(f'Integrity errors: {errors}')
exit(1)
print(f'All .ics files valid')
if HEARTBEAT_URL:
urllib.request.urlopen(HEARTBEAT_URL)
Run nightly:
# /etc/cron.d/radicale-ical-check
0 2 * * * python3 /opt/check-ical-integrity.py
In Vigilmon:
- New Monitor → Heartbeat
- Expected interval: 24 hours
- Copy the ping URL to
VIGILMON_ICAL_HEARTBEAT
Step 8: Slack alerts and status page
Slack alerts:
- Notifications → New Channel → Slack
- Paste your Slack incoming webhook URL
- Enable on all Radicale monitors
When the SSL cert is expiring:
⚠️ DEGRADED: radicale.yourdomain.com:5233/health
Keyword found: "expiring_in_12d"
Region: EU-West
Status page:
- Status Pages → New Status Page
- Add all monitors
- Share with users who depend on calendar sync
What you've built
| What | How |
|------|-----|
| HTTP availability | Vigilmon HTTP → port 5232 |
| CalDAV principal endpoint | Vigilmon HTTP → /.well-known/caldav |
| CardDAV principal endpoint | Vigilmon HTTP → /.well-known/carddav |
| Filesystem health | Write/read/delete probe in health script |
| Disk space | statvfs check in health script |
| TLS certificate expiry | Vigilmon SSL monitor + health script check |
| Authentication backend | htpasswd/LDAP check in health script |
| iCalendar integrity | Nightly cron heartbeat |
| Composite health | Vigilmon HTTP → port 5233 |
| Slack alerts | Vigilmon Slack notification channel |
| Status page | Vigilmon public status page |
Radicale is designed to be simple and reliable. Pair it with monitoring that catches the gaps between "process running" and "calendars actually syncing."
Next steps
- Add CardDAV-specific monitoring for your most critical address books using PROPFIND probes
- Monitor the Radicale process restart frequency — if it restarts more than once per day, investigate memory or config issues
- Set disk space alerts at 80% and 95% separately, since 80% gives you time to act before hitting the wall
Get started free at vigilmon.online.