15 — Radicale Calendar (and Contacts)¶
A self-hosted CalDAV / CardDAV server replacing iCloud Calendar as the source of truth for personal scheduling. Single Python daemon, files-as-truth storage (each calendar is a folder of .ics files on disk), Tailscale-only.
Now behind Caddy (May 2026) — replaces the
tailscale servesetup this page describes. Radicale's HTTPS front-end is no longertailscale serveon:443; it's the Caddy reverse proxy athttps://radicale.z2mini.gabrielgabrie.com(auto-renewing Let's Encrypt cert via Cloudflare DNS-01). The container binds127.0.0.1:5232only (the100.67.235.68:5232binding was dropped). So: - iOS Calendar account → Server =radicale.z2mini.gabrielgabrie.com(no port; HTTPS). Same fix it had before (real cert, no trust prompt), just via Caddy now instead oftailscale serve. - Thunderbird →https://radicale.z2mini.gabrielgabrie.com/gabriel/(the old plain-HTTPhttp://z2mini:5232/gabriel/no longer works — that binding is gone). - On the box itself:http://127.0.0.1:5232. - Recovery: the HTTPS front-end is now the Caddy stack under/data/docker/caddy/(rsync-able, folds into the nightly backup) — nottailscale serveconfig in tailscaled state. After a Z2 rebuild you bring up/data/docker/caddy/(with its.envCloudflare token), not re-runtailscale serve.Install Phase 3 / Stage 1.5 below ("HTTPS via
tailscale serve") is superseded — see 17-caddy.md instead. The iOS-18-forces-TLS reasoning that motivated it still holds; it's just Caddy supplying the cert now.Companion to 03-tailscale.md (transport), 17-caddy.md (the HTTPS front-end), 05-backups.md (the collections tree is in the nightly rsync — pure files-as-truth, no SQLite ceremony unlike Navidrome and Beszel; the calendars are also on the off-site drive).
Overview¶
Single-container Docker stack:
| Container | Image | Purpose |
|---|---|---|
radicale |
tomsquest/docker-radicale:${RADICALE_VERSION} |
CalDAV + CardDAV server, htpasswd auth, web UI for collection management |
The container is fronted by the Caddy reverse proxy, which supplies a real auto-renewing Let's Encrypt cert (required for iOS 18 — see Phase 3).
| Endpoint | Used by | Why |
|---|---|---|
http://127.0.0.1:5232 (plain HTTP, on the box only) |
on-server scripts, the container's healthcheck | Direct, no cert dependency. |
https://radicale.z2mini.gabrielgabrie.com (TLS, via Caddy) |
iOS Calendar, Thunderbird, the browser web UI — i.e. every actual client | The only way in from any tailnet device. iOS 18+ forces TLS on initial CalDAV verification regardless of the in-app "Use SSL" toggle; Caddy supplies a publicly-trusted Let's Encrypt cert (ACME DNS-01 via Cloudflare), so iOS connects with no trust prompts. |
Not exposed to the public internet — the container binds 127.0.0.1 only and Caddy listens on the tailnet interface only. Storage is a tree of .ics (calendars) and .vcf (contacts) files under /data/docker/radicale/data/collections/ — pure files, no database, trivially rsync-safe.
The same daemon serves both calendars (CalDAV) and contacts (CardDAV) on the same port. This page focuses on calendars; contacts will be wired in as a follow-up using the same auth and storage.
Design decisions¶
| Decision | Reasoning |
|---|---|
| Radicale over Baïkal / Nextcloud | Files-as-truth storage (each event is an .ics file on disk — same recoverability property as Immich's storage template); ~30 MB RAM single container; pure rsync backup with no DB dump ceremony; matches the lightweight single-binary pattern. Baïkal is similar but locks data in SQLite. Nextcloud has a much prettier web UI but is a 5+ container PHP platform with quarterly upgrade maintenance — too heavy for one calendar. |
Image: tomsquest/docker-radicale |
Best-maintained community image. Runs as non-root (UID 2999), drops Linux capabilities, read-only root filesystem, tmpfs for /tmp and /run. Same trust spectrum as henrygd/beszel. |
Bound to 127.0.0.1:5232, fronted by Caddy |
Same posture as everything else on the Z2 — never reachable on the tailnet directly or publicly; the only way in is https://radicale.z2mini.gabrielgabrie.com via Caddy. (Was 100.67.235.68:5232 + 127.0.0.1:5232 when tailscale serve fronted it; the tailnet-IP binding was dropped in the May 2026 Caddy migration.) |
HTTPS provided by Caddy (was tailscale serve) |
iOS 18+ Calendar forces TLS on initial CalDAV verification regardless of the in-app "Use SSL" toggle (verified via Radicale logs showing ~12 TLS ClientHellos rejected by the HTTP-only listener before iOS gives up). Caddy fronts Radicale at https://radicale.z2mini.gabrielgabrie.com with a publicly-trusted auto-renewing Let's Encrypt cert (ACME DNS-01 via Cloudflare) — zero cert maintenance, no trust profile on iOS. Originally done with tailscale serve --bg http://127.0.0.1:5232; that listener was retired when Caddy became the single ingress. See 17-caddy.md. |
| Auth: htpasswd with bcrypt | Single-user setup; one bcrypt line in /data/docker/radicale/config/users is sufficient. LDAP and OAuth would be premature complexity. |
Image pinning via ${RADICALE_VERSION} in .env |
Updates are deliberate (docker compose pull + restart), never automatic. Same posture as the other stacks. |
restart: unless-stopped (not always) |
Survives reboots, but docker compose stop actually stops. |
| Files-as-truth = pure rsync backup | No pg_dump like Immich, no SQLite online-backup like Navidrome / Beszel. The collections tree is a folder of .ics files; rsync handles it directly. In the nightly backup since May 2026 (config/ + data/collections/ rsync'd into /mnt/backup/current/service-config/radicale/), and on the off-site T5 too. |
| Outlook tenants stay as side accounts on iOS, not bridged into Radicale | Org-managed Microsoft 365 doesn't expose CalDAV. Bridges (DavMail and similar) are fragile under modern auth. Cleaner architecture: Conestoga Exchange remains an iOS account for native invite acceptance and writing school events; Conestoga's "Publish a calendar" .ics URL is subscribed in Thunderbird only (iOS already sees the Exchange version, no need to duplicate). |
| Laurier Outlook account dropped | Job ended; keeping a logged-in account against an org you no longer work for is attack-surface and credential-rotation hassle for zero ongoing benefit. Old events viewable via OWA in a browser if ever needed. |
What was considered and rejected¶
- Nextcloud (Calendar app only) — best-in-class FOSS calendar web UI, near-iCloud.com polish, includes a proper Tasks app. Rejected because it's a 5+ container PHP platform requiring quarterly major-version upgrade care; one calendar doesn't justify operating a groupware suite. The "fewer moving parts means less can break during exam crunch" argument won out.
- Baïkal — sabre/dav-based, same protocol guarantees as Radicale, slightly more polished admin UI. Rejected because storage is SQLite (vs Radicale's flat files), so backups need ceremony, and the admin GUI is unnecessary for a single user.
- Nextcloud AIO — would have packaged the multi-container stack into one master container with built-in BorgBackup. Same rejection reason as classic Nextcloud — still operating a platform.
- AgenDAV / InfCloud / SOGo — third-party web frontends that consume external CalDAV (so could pair with Radicale). Rejected — the UIs all look 2014; if pretty UI matters enough to take on a separate frontend, Nextcloud's UI is better.
- DavMail and similar Outlook→CalDAV bridges — fragile under modern auth, frequent breakage. Solving the wrong problem (Conestoga Exchange already works fine on iOS natively).
- Apple Calendar export tools (icloud-calendar-export and similar) — unmaintained and depend on undocumented iCloud endpoints. The publish-as-iCal trick on iCloud.com is universal and uses Apple's official sharing feature.
vdirsyncerfor CalDAV-to-CalDAV migration — would have preserved alarms (the iCloud "Public Calendar" feed strips VALARMs by design). Rejected during this migration because (a) past-event alarms don't matter, (b) future alarms get re-added in Radicale once and persist forever, and © it adds a Python tool with an Apple app-specific-password dependency. Worth revisiting if a future migration involves alarms-as-data — but for typical iCloud → Radicale, accept the alarm loss and re-add the ~10 that actually matter (recurring bills, key class times). |- Self-signed cert + iOS profile install — would have avoided needing
tailscale serve, but requires installing a custom CA root via a.mobileconfigprofile on iOS, which is fiddly and a perpetual "trust this device" footnote. Tailscale's Let's Encrypt path uses a publicly-trusted CA so iOS connects without any trust prompt.
Install¶
Directory layout¶
/data/docker/radicale/
├── docker-compose.yml ← stack definition, version-pinned
├── .env ← version pin (no secrets, default 644)
├── config/ ← Radicale config + htpasswd users file
│ ├── config ← Radicale's main config file (INI format)
│ └── users ← htpasswd, one line per user, bcrypt (mode 644 — see permissions note below)
└── data/
└── collections/ ← THE source of truth — calendars and contacts as files
└── collection-root/
└── gabriel/
├── personal/ (one calendar)
│ ├── <uuid>.ics (one event per file)
│ └── .Radicale.props (collection metadata: displayname, color)
├── deadlines/
└── ...
The image runs as UID 2999. Permissions are handled in two ways:
data/— left owned bygabriel:gabrielat creation; the container auto-chowns it to2999:2999on first boot (the image has theCHOWNcapability and follows its documented "Option 0" pattern). No manualchownneeded.config/— stays owned bygabriel:gabriel.config/usersis set to mode 644 — the file contains bcrypt hashes, which are designed to be safe at the filesystem level (the whole point of password hashing is that exposure of the hash file is not a credential leak). Apache's default for htpasswd files is also 644. The container reads it via mode-644 world-read, no chown needed.
docker-compose.yml¶
Mirrors the upstream tomsquest/docker-radicale template verbatim except for these intentional customizations:
- Version pinned via
${RADICALE_VERSION}from.env(not:latest). - Port published on
127.0.0.1:5232only — fronted by Caddy athttps://radicale.z2mini.gabrielgabrie.com(see 17-caddy.md). (Pre-Caddy this stack also bound100.67.235.68:5232for direct Thunderbird/script access; that binding was dropped when Caddy took over.) TZset explicitly toAmerica/Toronto.- Bind-mount paths anchored under
/data/docker/radicale/.
name: radicale
services:
radicale:
container_name: radicale
image: tomsquest/docker-radicale:${RADICALE_VERSION}
restart: unless-stopped
init: true
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
- KILL
deploy:
resources:
limits:
memory: 256M
pids: 50
healthcheck:
test: curl -f http://127.0.0.1:5232 || exit 1
interval: 30s
retries: 3
environment:
TZ: America/Toronto
ports:
- "127.0.0.1:5232:5232"
volumes:
- "/data/docker/radicale/data:/data"
- "/data/docker/radicale/config:/config:ro"
.env¶
# Version pin. Verify current stable:
# curl -s https://hub.docker.com/v2/repositories/tomsquest/docker-radicale/tags/?page_size=10 | python3 -m json.tool | grep '"name"'
# tomsquest tags follow the upstream Radicale version + an image revision
# suffix, e.g. "3.5.2.0", "3.5.2.1". Pin to a specific tag, never to "latest".
RADICALE_VERSION=<set at install time>
config/config — Radicale main config¶
[server]
hosts = 0.0.0.0:5232
max_connections = 20
max_content_length = 100000000
timeout = 30
[auth]
type = htpasswd
htpasswd_filename = /config/users
htpasswd_encryption = bcrypt
[storage]
filesystem_folder = /data/collections
[rights]
type = owner_only
[logging]
level = info
Notes:
hosts = 0.0.0.0:5232binds inside the container; the Docker port mapping (127.0.0.1:5232:5232) is what restricts access to the host's loopback (Caddy reaches it there and is the only thing exposed on the tailnet).htpasswd_encryption = bcrypt— bcrypt is the secure default. The image ships with bcrypt support compiled in.rights = owner_only— only the user who owns a collection can read/write it. With one user this is moot, but it's the safe default if a second user is ever added (e.g., for a shared family calendar).
Phase 1 — set up directories and credentials¶
No sudo is needed for the install itself: /data/docker/ is gabriel-owned, the container auto-chowns its own data dir on first boot, and config/users is mode 644 (bcrypt hashes don't need filesystem-level secrecy).
Generate the bcrypt hash. The image's README claims htpasswd is included, but as of 3.7.2.0 it isn't actually shipped — bcrypt lives in a virtualenv at /venv/ instead. Use Python directly via that venv:
docker run --rm --entrypoint /venv/bin/python3 tomsquest/docker-radicale:<RADICALE_VERSION> \
-c "import bcrypt; print('gabriel:' + bcrypt.hashpw(b'<your-password>', bcrypt.gensalt(rounds=12)).decode())"
This prints a single line like gabriel:$2b$12$.... Save it to config/users:
nano /data/docker/radicale/config/users # paste the line, save
chmod 644 /data/docker/radicale/config/users
Why bcrypt and not plain or md5: plain stores the password readable, md5 is a fast hash (brute-forceable on modern hardware). bcrypt is slow-by-design and the standard for password storage.
gensalt(rounds=12)picks cost factor 12 — current best practice. Python'sbcryptproduces$2b$prefix; htpasswd produces$2y$. Radicale accepts both.
Write config/config and docker-compose.yml and .env with the contents above. The .env has no secrets (only the version pin), so a strict 600 isn't required — leave it at default 644:
docker compose config --quiet # validate YAML + variable resolution
docker compose pull # ~80 MB
docker compose up -d
docker compose ps # confirm "Up X seconds"
docker compose logs --tail 30 radicale # should show "Listening on 0.0.0.0:5232"
Phase 2 — first-boot smoke test from the laptop¶
Open https://radicale.z2mini.gabrielgabrie.com/ in your browser. You should get a basic login page (Radicale's built-in web UI). Log in with gabriel and the password you just set.
The web UI is minimal — it lets you create, rename, and delete collections, but it's not a calendar viewer. That's Thunderbird's job (next phase).
Phase 3 — HTTPS via Caddy (required for iOS)¶
iOS 18+ Calendar forces TLS on the initial CalDAV verification regardless of the in-app "Use SSL" toggle — plain HTTP http://127.0.0.1:5232 is fine for Thunderbird and on-server scripts, but iOS will refuse to add the account against it. The fix is the Caddy reverse proxy: it serves https://radicale.z2mini.gabrielgabrie.com with a real auto-renewing Let's Encrypt cert (ACME DNS-01 via Cloudflare), and Radicale's container is bound to 127.0.0.1:5232 only (Caddy reaches it there).
Historical note: this was originally done with
sudo tailscale serve --bg http://127.0.0.1:5232(frontinghttps://z2mini.elk-kanyu.ts.net/). That listener was retired in May 2026 when Caddy became the single HTTPS ingress — the iOS-18-forces-TLS reasoning is the same, Caddy just supplies the cert now and on a nicer hostname.
The Caddyfile block (already present — see 17-caddy.md):
Verify end-to-end (PROPFIND should return 207 Multi-Status):
curl -s -o /dev/null -w 'HTTP %{http_code}\n' \
-X PROPFIND -H 'Depth: 0' \
-u 'gabriel:<password>' \
https://radicale.z2mini.gabrielgabrie.com/gabriel/
Recovery: the HTTPS front-end is now the Caddy stack under
/data/docker/caddy/(rsync-able, folds into the nightly backup) — nottailscale serveconfig in tailscaled state. After a Z2 rebuild, bring up/data/docker/caddy/(docker compose build && up -d, with the.envCloudflare token present); the Caddyfile already has Radicale's block.
Migration from iCloud — phased walkthrough¶
The migration runs in seven phases with a deliberate two-week parallel-run window. Don't compress this — you're moving years of personal scheduling data and the verification step is what catches subtle problems (alarms not firing, recurring events drifting, time-zone bugs at DST boundaries).
Phase 1 — Stand up Radicale (above)¶
Done. Install Phases 1-3 complete: container running, web UI reachable at http://127.0.0.1:5232/ on the box, HTTPS reachable at https://radicale.z2mini.gabrielgabrie.com/ via Caddy.
Phase 2 — Connect Thunderbird first, round-trip a test event¶
Thunderbird is easier to debug than iOS. Get the round-trip working here before touching the iPhone.
- Install Thunderbird (latest ESR or release) on the Windows laptop.
- Skip the email-account setup wizard if it appears.
- File → New → Calendar → On the Network → Next.
- Username:
gabriel. Location:https://radicale.z2mini.gabrielgabrie.com/gabriel/. Click "Find Calendars." - Thunderbird discovers the existing collections (none yet). To create the first one, use Radicale's web UI: log in at
https://radicale.z2mini.gabrielgabrie.com/, click "+ New collection," set type tocalendar, namepersonal, pick a color. Save. - Back in Thunderbird, "Find Calendars" again —
personalappears. Subscribe. - Create a test event in Thunderbird (any time, any title, set an alarm). Confirm:
- The event appears in Thunderbird's calendar grid.
- On the server:
ls /data/docker/radicale/data/collections/collection-root/gabriel/personal/shows a<uuid>.icsfile. catthat file — you should see the event details in iCalendar format.- Delete the event in Thunderbird; the file disappears on the server.
If all four checks pass, the CalDAV round-trip works. Move on.
Phase 3 — Export each iCloud calendar as .ics¶
Per iCloud calendar, on iCloud.com (in a desktop browser, signed in to your Apple ID):
- Open the Calendar app.
- Click the share icon next to the calendar name in the left sidebar.
- Choose "Public Calendar." Copy the URL — it'll look like
webcal://p##-caldav.icloud.com/published/2/.... - Replace
webcal://withhttps://in your browser's address bar and hit enter. The browser downloads the full.ics(entire history, not just 12 months). - Save with a clear local name, e.g.,
icloud-events-and-reminders.ics. - Disable public sharing immediately on iCloud.com — this URL is public while enabled.
- Repeat for every iCloud calendar that needs to migrate.
Calendars to migrate (per the Apple Calendar app on iPhone):
- Events and Reminders
- Deadlines
- Conestoga Timetable (hand-entered, so it migrates as a regular calendar)
- Bills
- Work Schedule
Note on iCloud.com UI: Apple has shuffled the share/publish controls across redesigns over the years. If the wording or button placement above doesn't match what you see, the underlying feature (publish a calendar as iCal) has been there continuously since ~2014 — look for "Public Calendar," "Share Calendar," or a person-with-plus icon next to the calendar name. If the UI has moved entirely, an alternate path is via the Calendar app on a borrowed Mac (File → Export → Export as
.ics).The alarm-stripping caveat: iCloud's "Public Calendar" feed deliberately strips all
VALARMcomponents — Apple treats alarms as personal preferences that shouldn't leak to subscribers. So events come across via this path with no reminders attached. For past events that doesn't matter, but for aBillsorDeadlinescalendar where the alarm IS the point, you'll need to re-add alarms manually after import (set once on each recurring event in Thunderbird or iOS, persists forever after via CalDAV). The 2026-05-10 migration moved 255 events across 5 calendars and accepted alarm loss as the trade for keeping migration tooling simple. Alternative paths preserving alarms — a borrowed Mac's Calendar.app export, orvdirsyncerfor CalDAV-to-CalDAV — are evaluated and rejected in Considered/Rejected above.
Phase 4 — Create matching Radicale collections, import each .ics¶
For each exported .ics:
- In Radicale's web UI, create a collection with the same name and a matching color (use the iCloud color as a reference — Apple's colors are roughly: red, orange, yellow, green, blue, purple, brown).
- In Thunderbird, "Find Calendars" again to subscribe to the new collection.
- Right-click the calendar in Thunderbird's list → Import → choose the
.icsfile. Thunderbird parses and uploads each event via CalDAV. - Sanity-check after each import:
- A known recent event appears at the right time, with the right alarm.
- A known old event (from a year+ ago) appears.
- A recurring event (e.g., a weekly class) shows up across the whole recurrence range.
- A timezone-edge event (one near a DST boundary if you have one) is correct.
If a calendar has hundreds of events, the import takes a minute or two. Watch the server: du -sh /data/docker/radicale/data/collections/ grows as files appear.
Phase 5 — Add Radicale to iOS, set up subscribed feeds¶
On the iPhone:
- Settings → Calendar → Accounts → Add Account → Other → Add CalDAV Account.
| Field | Value |
|---|---|
| Server | radicale.z2mini.gabrielgabrie.com |
| User Name | gabriel |
| Password | (your htpasswd password) |
| Description | Radicale |
- Tap Next. iOS validates against
https://radicale.z2mini.gabrielgabrie.com/(port 443, default) — Caddy handles this with a real Let's Encrypt cert (DNS-01 via Cloudflare). Validation completes in a couple of seconds, no SSL toggle to flip. - Save. iOS auto-discovers all your Radicale calendars; they appear under a new "Radicale" section in the Calendar app, alongside iCloud and the Outlook accounts.
For subscribed feeds (read-only .ics URLs), still on the iPhone:
- Settings → Calendar → Accounts → Add Account → Other → Add Subscribed Calendar.
- Enter the
.icsURL (no auth needed for the publish-as-iCal feeds; the clinic feed may have its own URL format). - iOS adds it under a "Subscribed" pseudo-account. Refresh interval defaults to "Weekly" — bump to "Daily" or "Every Hour" depending on volatility.
Subscribe to:
- The clinic feed (Conestoga College Student Massage Clinic Appointments) — same URL it had under iCloud.
- The Conestoga "Publish a calendar" feed if/when you want it on iOS as well. Per the iOS visibility plan you may keep this feed unsubscribed on iOS (since the Conestoga Exchange account already shows the same events in fuller detail) and subscribe to it on Thunderbird only.
In Thunderbird, do the same:
- File → New → Calendar → On the Network → ICS / iCalendar.
- URL: the
.icsfeed. - Choose "read-only." Subscribe.
Repeat for each feed (clinic, Conestoga publish, Laurier publish if you decide to keep it for archival).
Phase 6 — Two-week parallel-run window¶
This is the verification phase. Don't skip it.
- Make all new events in Radicale only. Use the Radicale calendars in iOS or Thunderbird as the write target; never iCloud.
- Keep iCloud Calendar logged in but treat it as read-only — useful as a safety net to compare against, but don't add to it.
- Watch for alarms firing on the right device at the right time — this catches CalDAV alarm-format quirks if any exist.
- Watch for recurring events behaving correctly across DST. If you have a weekly event, the next DST transition is the natural test.
- Watch for time zone correctness on travel — events created on iOS in one timezone should display correctly when the phone moves to another.
If any of these surface problems, fix them before moving on. Common gotchas are listed in the Troubleshooting section below.
Phase 7 — Cut iCloud Calendar¶
After two weeks of clean parallel-run:
- iOS: Settings → [your name] → iCloud → Calendars → toggle OFF.
- iOS prompts: "Keep on iPhone" or "Delete from iPhone." Choose Delete — Radicale is now the master and you don't want stale duplicates.
- iCloud.com → Calendar — the calendars are still in iCloud's storage. Right-click each calendar → Delete. This is what actually purges the data from Apple's servers.
- Repeat the iOS step for any other Apple devices on the same iCloud account.
- The iCloud Calendar service is now disabled. Calendar storage moved fully to the Z2 Mini. iCloud subscription quota reclaim is negligible (calendars are tiny) but the service is no longer touching Apple.
Don't disable iCloud Calendar before completing Phase 6 cleanly. If the parallel-run window surfaces a problem with Radicale and you've already cut iCloud, recovery is much harder.
Phase 8 — Wired into the nightly backup (done, May 2026)¶
~/scripts/backup-files.sh rsyncs /data/docker/radicale/.env, docker-compose.yml, config/ (the INI config + the htpasswd users file), and data/collections/ (all the calendars/events — plain .ics files, "files-as-truth", no DB) into /mnt/backup/current/service-config/radicale/. No pg_dump, no SQLite online-backup ceremony — rsync handles it directly. (It also picks up Radicale's .Radicale.cache/ along the way — harmless.) Verified byte-for-byte after the first run. The calendars are also on the off-site T5 via backup-offsite.sh (the whole service-config/ tree goes off-site). See 05-backups.md.
Restore: rsync service-config/radicale/config/ and service-config/radicale/data/collections/ back into /data/docker/radicale/, fix ownership (sudo chown -R 2999:2999 /data/docker/radicale/data /data/docker/radicale/config), docker compose up -d. Files-as-truth means the collections directory alone is enough — drop it into a fresh Radicale and every calendar comes back, no schema migration. See 08-recovery.md → Step 6b.
Final calendar map¶
The end-state architecture, after Phase 7 completes:
iOS — what's logged in and visible¶
| Source | Account type | Direction | Visible in Calendar app | Purpose |
|---|---|---|---|---|
Radicale (personal, deadlines, conestoga-timetable, bills, work-schedule) |
CalDAV | Read + Write | Yes | Primary; all new personal events go here |
| Conestoga Outlook | Exchange | Read + Write | Yes | School events; native invite acceptance and RSVP |
| Gmail | Google account | Read + Write | Yes | Side account; invitations occasionally arrive here |
| Clinic feeds | Subscribed (.ics) |
Read-only | Yes | Appointment reminders |
| Conestoga publish feed | Subscribed (.ics) |
Read-only | Hidden | Conestoga Exchange already shows these in fuller detail |
| Canadian Holidays / Birthdays / Siri Suggestions | Built-in iOS | Read-only | Yes | iOS-managed; untouched by this migration |
| iCloud Calendar | iCloud | — | Disabled | Removed in Phase 7 |
| Laurier Outlook | Exchange | — | Removed | Job ended; account dropped |
Thunderbird (Windows laptop) — what's configured¶
| Source | Account type | Direction | Visible | Purpose |
|---|---|---|---|---|
| Radicale (all calendars) | CalDAV | Read + Write | Yes | Primary; same calendars as iOS |
| Conestoga publish feed | Subscribed (.ics) |
Read-only | Yes | Lets the laptop see school events Thunderbird can't reach via Exchange |
| Laurier publish feed (optional) | Subscribed (.ics) |
Read-only | Optional | Archival access to Laurier events; can drop entirely |
| Clinic feeds | Subscribed (.ics) |
Read-only | Yes | Same feeds as iOS |
The asymmetry to remember: iOS sees Conestoga school events through the Exchange account (full detail — body text, attendees, attachments). Thunderbird sees them through the publish-as-iCal feed (titles + locations only — that's Conestoga's tenant policy). When you create a school event from iOS, it lands in the Exchange calendar and shows up in Thunderbird via the publish feed with reduced detail. That's working as intended given the constraint that Microsoft 365 doesn't expose CalDAV.
Operations¶
Start / stop / pull updates¶
cd /data/docker/radicale
docker compose up -d # start (or recreate after pull)
docker compose stop # stop the container
docker compose down # stop and remove (data preserved)
# Updates: bump RADICALE_VERSION in .env, then:
docker compose pull
docker compose up -d
Logs¶
Auth failures appear here as Failed login attempt for user 'gabriel' lines. Useful for diagnosing iOS or Thunderbird connection issues.
Disk usage¶
A typical personal calendar archive (~10 years of events across a handful of calendars) is single-digit MB. Contacts add another few MB at most. Trivial.
Inspect a collection at the file level¶
ls /data/docker/radicale/data/collections/collection-root/gabriel/
ls /data/docker/radicale/data/collections/collection-root/gabriel/personal/
cat /data/docker/radicale/data/collections/collection-root/gabriel/personal/<uuid>.ics
Each .ics file is a single VEVENT (or VTODO for tasks). Human-readable. This is the "files-as-truth" property — if Radicale ever dies, your calendar data is just files.
Add a new calendar (collection)¶
Easiest path: log in to https://radicale.z2mini.gabrielgabrie.com/, click "+ New collection," pick type and color. Both Thunderbird and iOS auto-discover the new collection on next sync.
Alternative: create the directory directly under data/collections/collection-root/gabriel/ with a .Radicale.props file. The web UI does this for you correctly; only do it by hand if you know what you're doing.
Connecting from on the server itself¶
Same gotcha as Immich, Navidrome, Homepage, Beszel — the server is bound to the Tailscale interface IP only. Scripts running on z2mini must use:
http://127.0.0.1:5232(Z2's tailnet IPv4), orhttps://radicale.z2mini.gabrielgabrie.com(FQDN — bypasses/etc/hosts).
http://localhost:5232 and https://radicale.z2mini.gabrielgabrie.com (short hostname) won't work from the server itself. From other tailnet devices (laptop, iPhone), https://radicale.z2mini.gabrielgabrie.com works fine.
Manual backup ad-hoc¶
The nightly backup-files.sh already covers Radicale (config/ + data/collections/ → /mnt/backup/current/service-config/radicale/). To force a fresh copy out-of-cycle, just run the nightly script (~/scripts/backup-files.sh) or, for just Radicale:
sudo rsync -av --delete \
/data/docker/radicale/data/collections/ \
/mnt/backup/current/service-config/radicale/data/collections/
sudo rsync -av --delete \
/data/docker/radicale/config/ \
/mnt/backup/current/service-config/radicale/config/
Restore is the reverse — copy back into /data/docker/radicale/, fix ownership (sudo chown -R 2999:2999 /data/docker/radicale/data /data/docker/radicale/config), docker compose up -d.
Backup considerations¶
Radicale is in the nightly backup (since May 2026 — see 05-backups.md). Everything under /data/docker/radicale/ that matters is rsync'd into /mnt/backup/current/service-config/radicale/:
data/collections/— the calendars and contacts as plain.ics/.vcffiles. Pure rsync, no ceremony. This is the load-bearing one — files-as-truth means this directory alone restores every calendar. Also on the off-site T5 viabackup-offsite.sh. (The rsync also pulls Radicale's.Radicale.cache/— harmless.)config/users— htpasswd file. Mode 644 (bcrypt hashes don't need filesystem-level secrecy). Tiny but load-bearing — without it, no one can authenticate.config/config— main Radicale config. Tiny, rsync-safe..env— version pin only (no secrets). Default mode 644.docker-compose.yml— the stack definition.
What's not in this backup: nothing of Radicale's, really — there's no live database to skip. (The .Radicale.cache/ that comes along is regenerable but harmless to keep.)
The HTTPS front-end is the Caddy stack (/data/docker/caddy/ — Caddyfile + .env Cloudflare token), which is under /data/docker/ and is in the nightly backup (service-config/caddy/). After a Z2 rebuild: bring up the Caddy stack (docker compose build && up -d); its Caddyfile already has Radicale's radicale.z2mini.gabrielgabrie.com block. See 17-caddy.md. (No more tailscale serve re-init step — that's retired.) Full disaster-recovery walkthrough: 08-recovery.md → Step 6b.
The "files-as-truth" property means if the entire Radicale install is destroyed, the collections directory alone is enough to restore — drop it into a fresh Radicale install and every calendar comes back. No DB schema versions to match, no migration to run.
Troubleshooting¶
iOS reports "CalDAV account verification failed" on initial setup:
This is the iOS-18-forces-TLS issue. Server logs (docker compose logs --since 5m radicale) will show binary garbage starting with \x16\x03\x01... — those are TLS ClientHello records being rejected by Radicale's HTTP-only listener. The fix is Install Phase 3 (Caddy fronts it on :443 with a real cert).
Once Phase 3 is in place, the iOS account uses:
- Server:
radicale.z2mini.gabrielgabrie.com(no port — defaults to 443/HTTPS) - Use SSL: ON (it's actually HTTPS now via the Caddy reverse proxy)
If verification still fails after Phase 3 is configured:
- Confirm the iPhone is on the tailnet (Tailscale app shows "Connected") and can resolve
radicale.z2mini.gabrielgabrie.com(the Tailscale global resolvers 1.1.1.⅛.8.8.8 handle that). - Confirm
docker compose -f /data/docker/caddy/docker-compose.yml psshows caddy healthy. - From the laptop, browse to
https://radicale.z2mini.gabrielgabrie.com/— should reach Radicale's login. If that fails too, either Caddy is misconfigured or the cert lapsed (rare; Caddy auto-renews via DNS-01) — see 17-caddy.md.
iOS connects but no calendars appear:
- The CalDAV principal URL discovery needs the trailing slash:
radicale.z2mini.gabrielgabrie.comis right;.../gabrielis wrong (iOS treats the path component as a discovery prefix). Leave the path empty, let iOS discover. - Confirm at least one collection exists for user
gabrielvia the web UI athttps://radicale.z2mini.gabrielgabrie.com/.
Thunderbird "Find Calendars" returns nothing:
- URL must include the user path:
https://radicale.z2mini.gabrielgabrie.com/gabriel/(with trailing slash). - Check Thunderbird's error console (Tools → Developer Tools → Error Console) for CalDAV PROPFIND failures.
Auth failures despite correct password:
- Confirm the htpasswd file is mode 644 (so the container at UID 2999 can read it through gabriel-owned, world-readable mode bits). If it's mode 600 owned by gabriel, UID 2999 can't read it and every auth fails silently with a generic 401.
- Confirm the htpasswd line starts with
$2b$or$2y$(bcrypt) and the config hashtpasswd_encryption = bcrypt.
Imported events have wrong times:
- Most likely a TZID mismatch — events exported from iCloud carry their original timezone. Thunderbird and iOS both honor TZID correctly, but if Radicale's container TZ is wrong it can affect logging. Confirm
TZ: America/Torontois set in the compose file.
Recurring event "stops" partway:
- Some Apple calendars cap recurrence on export at a specific date. Reopen the event in Thunderbird, extend the recurrence rule, save. The fix syncs back to iOS via Radicale.
docker compose up -d fails with permission errors:
- The host directory
/data/docker/radicale/datashould be writable by the container (the image's "Option 0" pattern auto-chowns it on first boot via theCHOWNcapability). If you removedCHOWNfromcap_add, re-add it or chown the dir manually to2999:2999. Theconfig/dir stays owned by gabriel — onlyusersneeds to be world-readable (mode 644).
Container exits immediately on start:
- Check
docker compose logs radicalefor the specific error. Most common: malformedconfig/config(missing[storage]section, typo'd key) or unreadable htpasswd file.
htpasswd: not found when generating the hash:
- The image's README lists
htpasswdas included, but as of3.7.2.0it isn't shipped. Use the venv Python form documented in Phase 1 instead —bcryptis available at/venv/lib/python3.12/site-packages/bcrypt.