tutorial

Monitoring Your Self-Hosted Radicale CalDAV/CardDAV Server with Vigilmon

Production monitoring for Radicale — HTTP availability, CalDAV calendar sync, CardDAV contacts sync, authentication backend, filesystem storage, SSL certificate expiry, and iCalendar integrity checks.

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 .ics and .vcf files 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:

  1. Click New Monitor → HTTP
  2. URL: https://radicale.yourdomain.com/
  3. Expected status: 200
  4. Keyword check: Radicale works
  5. Check interval: 1 minute

CalDAV principal endpoint:

  1. New Monitor → HTTP
  2. URL: https://radicale.yourdomain.com/.well-known/caldav
  3. Expected status: 301 or 302 (redirect to the user's calendar root)
  4. Check interval: 5 minutes

CardDAV principal endpoint:

  1. New Monitor → HTTP
  2. URL: https://radicale.yourdomain.com/.well-known/carddav
  3. Expected status: 301 or 302
  4. Check interval: 5 minutes

Step 4: Monitor the health check wrapper

Composite health monitor:

  1. New Monitor → HTTP
  2. URL: https://radicale.yourdomain.com:5233/health
  3. Expected status: 200
  4. Keyword check: "status":"ok"
  5. 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:

  1. New Monitor → SSL Certificate
  2. Domain: radicale.yourdomain.com
  3. Alert when: 30 days before expiry
  4. 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:

  1. New Monitor → Heartbeat
  2. Expected interval: 24 hours
  3. Copy the ping URL to VIGILMON_ICAL_HEARTBEAT

Step 8: Slack alerts and status page

Slack alerts:

  1. Notifications → New Channel → Slack
  2. Paste your Slack incoming webhook URL
  3. 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:

  1. Status Pages → New Status Page
  2. Add all monitors
  3. 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.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →