A self-hosted, multi-user CalDAV, CardDAV and SyncML server for calendars, tasks, notes and contacts — in the spirit of Baïkal — built on .NET 11 and SQLite and designed to run in Docker on Linux behind a reverse proxy such as Caddy.
- Events (
VEVENT), tasks (VTODO) and notes/journals (VJOURNAL) over CalDAV - Contacts (vCard 3.0/4.0) over CardDAV
- SyncML (OMA DS 1.1/1.2, XML and WBXML) for phones and apps that predate CalDAV, with vCard 2.1 / vCalendar 1.0 / vNote conversion
- Multi-user with sharing of calendars and address books with people or groups (read-only or read/write), and calendar delegation
- Meeting invitations: server-side scheduling (RFC 6638) with delivery to local users and iMIP email to everyone else
- Instant sync for DAVx⁵ via WebDAV-Push (Web Push / UnifiedPush)
- Trash with restore, storage quotas, calendar subscriptions (webcal), public calendar links
- Generated birthday calendar and a global address book of all users
- Migration from another CalDAV/CardDAV server (Baïkal, Nextcloud, Radicale, iCloud, …): calendars, tasks, notes and contacts
- vCard 3.0/4.0 negotiation: each client gets the version it asks for
- Web management UI with first-run admin setup, user administration, import/export
- Authentication: local passwords, per-account IMAP passthrough, per-device app passwords, optional OpenID Connect for the UI
- Client autoconfiguration: Thunderbird autoconfig (incl. CalDAV/CardDAV), Outlook Autodiscover, Apple configuration profiles,
/.well-knowndiscovery - Single SQLite file for storage; only Microsoft packages are used
cp .env.example .env # set DAV_HOST, AUTOCONFIG_HOST, AUTODISCOVER_HOST
docker compose up -d --buildThe compose file runs the app and Caddy with automatic HTTPS (Caddyfile). The app port is not published; only Caddy listens on 80/443. Open https://<DAV_HOST>/; on first start you are redirected to /setup to create the first administrator. Set SETUP_TOKEN in .env if the server is reachable before you finish setup.
Everything is stored in the caldav-data volume (nullify-caldav.db plus the keys/ folder used to encrypt cookies and secrets — back both up together). The container runs as the non-root app user (UID 1654); when bind-mounting a host directory make it writable for that UID.
The app assumes it runs behind a TLS-terminating reverse proxy. It honours X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host from loopback and private networks (10/8, 172.16/12, 192.168/16, 100.64/10, fc00::/7), which covers Caddy in the same Docker network or on the Docker host. Caddy sends these headers by default.
Using a different proxy, or one on a public address:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
client_max_body_size 32m;
}and set ReverseProxy__KnownProxies to its address. Basic authentication is used by DAV and SyncML clients, so never expose the app over plain HTTP.
dotnet run --project src/Nullify.CalDavData is written to src/Nullify.CalDav/data unless Storage__DataDirectory is set.
| Setting | Value |
|---|---|
| CalDAV/CardDAV URL | https://<host>/dav/ (or just the host name — /.well-known/caldav and /.well-known/carddav are supported) |
| SyncML URL | https://<host>/syncml (also /sync and /funambol/ds) |
| Username | your username or email address |
| Password | an app password from Profile → App passwords, or your account password if allowed |
Each user's profile page offers an Apple configuration profile (.mobileconfig) that adds CalDAV, CardDAV and — when a mail server is configured — the IMAP/SMTP account in one step.
The server implements the requests used by Apple Calendar/Reminders/Contacts, DAVx⁵ (with jtx Board / OpenTasks / Tasks.org for tasks and notes), Thunderbird, Evolution and KOrganizer; the automated tests exercise these request patterns but not the clients themselves.
Import (in the top menu) copies data from an existing CalDAV/CardDAV server into the signed-in account:
- Enter the old server's address (as used in your apps, e.g.
https://example.com/baikal/dav.php/or justhttps://dav.example.com/), username and password. Basic and Digest authentication are supported, so Baïkal works with either setting. For iCloud usehttps://caldav.icloud.com/andhttps://contacts.icloud.com/with an app-specific password. - The server finds your calendars and address books through
/.well-known,current-user-principaland the home sets, following redirects. Scheduling inboxes and server-side subscriptions are skipped. Read-only collections (shared with you, generated) are listed but not ticked. - Choose what to import and start. The job runs in the background with progress on the page; each remote collection becomes a new local one with the same name, colour, description and content types, and items keep their file names and UIDs.
- Run it again at any time before switching your devices: new items are added and changed items updated (matched by UID). Nothing is ever changed on the old server, and nothing local is deleted.
The old server's password is kept encrypted (data protection key ring) so the import can be re-run; Remove source deletes it. Storage quotas apply to imported data.
Importing for another user / from the local network. Regular users can only reach servers on the public internet (unless Allow outgoing requests to private networks is enabled server-wide). Administrators get an Import into picker on the Import page (also linked as Import into this account on each user's admin page): they can connect to a server on the local network, such as an internal Baïkal, and import directly into that user's account. The source then appears in the user's own list, and the user can re-run it against the same server; the administrator's network approval lapses if the source is reconnected to a different address.
Add an account with Login with URL and user name, using https://<host>/dav/ and an app password. DAVx⁵ then finds:
- your calendars and address books, plus anything shared with you directly or through a group;
- calendars of people who made you a delegate, via the proxy principals in your group memberships (enable Refresh collection list if they do not appear straight away);
- the read-only Birthdays calendar, the Directory address book and any subscriptions, all marked read-only;
- WebDAV-Push: install a UnifiedPush distributor (e.g. ntfy) and DAVx⁵ registers for push on each collection, so changes from other devices arrive within seconds instead of at the next poll;
- invitations: events you create with attendees are scheduled by the server (DAVx⁵ relies on this), and replies from local attendees update your copy automatically.
With Settings → Meeting invitations enabled (the default), saving an event or task that has attendees triggers implicit scheduling (RFC 6638 calendar-auto-schedule):
| Action | Local attendee or organizer | External address |
|---|---|---|
| Organizer creates or significantly changes an event | Copy delivered to the attendee's default calendar and inbox | iMIP REQUEST email |
| Organizer removes an attendee or deletes the event | Attendee's copy marked CANCELLED |
iMIP CANCEL email |
| Attendee accepts, declines or tentatively accepts (or deletes) | Organizer's copy updated with the new PARTSTAT |
iMIP REPLY email |
- Delivery results are recorded as
SCHEDULE-STATUSon the attendee (1.2 delivered, 1.1 emailed, 5.1 not delivered). Attendees keep their own alarms. Schedule-Tag/If-Schedule-Tag-Matchare supported. Each user hasinbox/andoutbox/collections, and free/busy lookups can be POSTed to the outbox.- Emails need an SMTP server (Settings → Mail server), a sender address and optionally SMTP credentials under Meeting invitations. Send test email checks the setup. Messages come from the sender address with
Reply-Toset to the organizer. - Replies from external attendees go to the organizer's mailbox; the server does not read mailboxes, so those are processed by the organizer's mail or calendar app.
- Trash: deleted events, tasks, notes, contacts and whole collections are kept for the retention period (Settings, default 30 days), whichever client deleted them (CalDAV, CardDAV, SyncML or the web UI). Restore them from the Trash page.
- Quotas: set a default under Settings and per user on the user page. Clients see
quota-used-bytes/quota-available-bytes(RFC 4331); uploads over quota get507 Insufficient Storage. Generated collections and the trash do not count. - Subscriptions: Calendars → Subscribe to a calendar adds a read-only calendar that the server downloads from an
https://orwebcal://feed on a schedule, so every device sees it. Requests to loopback, private and link-local addresses are blocked unless the admin allows them (protection against server-side request forgery). - Public links: on a calendar's page, create a secret
webcal://link anyone can subscribe to, optionally showing only busy times. Links can be regenerated or disabled.
- Groups (Admin → Groups): share a calendar or address book with a group, and everyone in it sees it. Membership changes take effect immediately. Groups also appear as principals under
/dav/groups/. - Delegation (Profile → Calendar delegates): give someone read or read/write access to all your calendars. It is exposed through CalendarServer
calendar-proxy-read/calendar-proxy-writeprincipals, which Apple Calendar and DAVx⁵ understand. - Birthday calendar: generated per user from the
BDAYof contacts in their own address books and kept up to date. - Directory: a read-only address book in every user's account listing everyone on the server, for autocompleting colleagues.
- Both can be switched off server-wide under Settings.
Administrators can switch any account to IMAP passthrough (Users → edit → Password verification). The password is then verified by logging in to the IMAP server rather than against a local hash, for the web UI as well as for CalDAV, CardDAV and SyncML clients.
- The server-wide IMAP server is set under Settings → Mail server. Each account can override the host, port and security (TLS, STARTTLS or none).
- The IMAP login defaults to the account's email address, then its username, and can be overridden per account.
- Authentication uses
AUTHENTICATE PLAINwhen offered, otherwiseLOGIN. Server certificates are validated unless Accept invalid IMAP TLS certificates is enabled. - Successful checks are cached for five minutes, so a sync client does not cause an IMAP login per request. Failed attempts count towards the same brute-force throttle as local logins.
- App passwords and SSO keep working for IMAP accounts; the password change form is hidden for them.
- Settings → Test IMAP connection checks connectivity and TLS without logging in.
Configure Settings → Public address (or the PublicUrl environment variable, set by the compose file) and Settings → Mail server. The app then answers:
| Client | Endpoint |
|---|---|
| Thunderbird (mail + CalDAV + CardDAV) | /.well-known/autoconfig/mail/config-v1.1.xml, /mail/config-v1.1.xml (on autoconfig.<domain>) |
| Outlook / Microsoft Autodiscover (POX) | POST /autodiscover/autodiscover.xml (on autodiscover.<domain>) |
| Outlook Autodiscover v2 | /autodiscover/autodiscover.json?Protocol=AutodiscoverV1 |
| CalDAV/CardDAV discovery (RFC 6764) | /.well-known/caldav, /.well-known/carddav |
| Apple devices | .mobileconfig download on the profile page |
Mail settings are only published for the configured mail domains (all domains if none are listed). Exchange ActiveSync requests get the standard error response, so clients fall back to IMAP. Suggested DNS records:
autoconfig.example.com. CNAME dav.example.com.
autodiscover.example.com. CNAME dav.example.com.
_caldavs._tcp.example.com. SRV 0 1 443 dav.example.com.
_caldavs._tcp.example.com. TXT "path=/dav/"
_carddavs._tcp.example.com. SRV 0 1 443 dav.example.com.
_carddavs._tcp.example.com. TXT "path=/dav/"
_autodiscover._tcp.example.com. SRV 0 1 443 autodiscover.example.com.
On the autoconfig and autodiscover host names the Caddyfile exposes only the metadata endpoints. If your apex domain is served elsewhere, redirect its /.well-known/caldav and /.well-known/carddav to the DAV host (see the commented block in the Caddyfile).
SyncML clients POST to /syncml using basic credentials (syncml:auth-basic) in the message header or an HTTP Authorization header. Server databases:
| Database URI | Content |
|---|---|
contacts (card, addressbook, scard) |
First address book |
calendar (cal, agenda, scal) |
Events and tasks in the first calendar that supports events |
events |
Events only |
tasks (todo, stask) |
Tasks in the first calendar that supports tasks |
notes (memo, snote, journal) |
Notes stored as VJOURNAL |
Append /<collection> to choose a specific (own or shared) collection, e.g. calendar/work or contacts/team.
- Sync types 200–205 (two-way, slow, one-way and refresh in both directions), anchors, LUID mapping, multi-message packages honouring
MaxMsgSize,NumberOfChanges, device information exchange (Put/Get/Results). - Formats are chosen from the device's DevInf
Rx-Pref(or the type of the items it sends): vCard 2.1 ↔ 3.0, vCalendar 1.0 ↔ iCalendar 2.0 (recurrence rules, alarms, all-day events, quoted-printable), and plain text or vNote 1.1 ↔VJOURNAL. When a legacy device updates an item, the stored UID and properties vCalendar 1.0 cannot express (attendees, complex recurrence rules) are preserved. - In slow syncs the server matches the client's items to existing ones by UID, or by name (contacts) or summary and start time (events), so nothing is duplicated.
- Known devices are listed on each user's profile page; forgetting a device forces a full sync next time.
All settings can be provided as environment variables (Section__Key) or in appsettings.json.
| Variable | Default | Purpose |
|---|---|---|
Storage__DataDirectory |
/data in Docker |
Location of the SQLite database and key ring |
PublicUrl |
(empty) | Public base URL for autoconfiguration (overridable in the UI) |
Setup__Token |
(empty) | If set, the first-run setup page requires this token |
ReverseProxy__Enabled |
true |
Process X-Forwarded-* headers |
ReverseProxy__KnownNetworks |
private ranges | Comma-separated CIDR ranges of trusted proxies |
ReverseProxy__KnownProxies |
Comma-separated trusted proxy IPs | |
ReverseProxy__TrustAll |
false |
Trust forwarded headers from any address |
ReverseProxy__ForwardLimit |
1 |
Number of proxy hops to process (0 = unlimited) |
ReverseProxy__AllowedHosts |
Restrict accepted forwarded host names | |
PathBase |
Serve under a sub-path, e.g. /caldav |
|
ASPNETCORE_HTTP_PORTS |
8080 |
Listening port inside the container |
Runtime settings (mail server, autoconfiguration, SyncML, OIDC, whether account passwords may be used by sync clients, hiding the password form) are edited by administrators under Settings and stored in the database.
- Register a confidential (or public + PKCE) client with your provider using the redirect URI shown on the Settings page (
https://<host>/signin-oidc). - Under Settings → OpenID Connect enter the authority (issuer URL), client ID and secret, then enable it.
- Choose how accounts are matched:
- Link from profile (always available): a signed-in local user clicks Link SSO account.
- Auto-provision: unknown SSO users get a new account with a default calendar and address book.
- Link by username: an SSO identity is attached to an existing local account with the same username claim.
- Optionally map administrator rights from a claim (e.g.
groups=caldav-admins).
SSO applies to the web UI. Sync clients authenticate with app passwords, which SSO-only users create on their profile page. When the password form is hidden, /account/login?local=true remains available as a recovery path.
Owners share a calendar or address book with another user from its edit page, choosing read only or read & write. The shared collection appears automatically in the sharee's calendar/address book home (and can be targeted by SyncML as calendar/<name>), so it shows up in their clients without extra configuration. Clients see the access level through DAV:current-user-privilege-set; sharees may rename/recolour their own view, and deleting it from the client simply removes the share.
Calendars can also be shared from CalDAV clients, through either draft-pot-webdav-resource-sharing (POST of DAV:share-resource, DAV:invite, DAV:share-access) or CalendarServer sharing (POST of CS:share, CS:invite, CS:allowed-sharing-modes); both are advertised in the DAV header. Sharees are named by mailto: address, user principal or group principal (/dav/groups/<name>/), and no-access or CS:remove stops sharing. As in the web UI, a share takes effect at once (no invitation to accept), and only the calendar's owner can change it.
- WebDAV (RFC 4918):
OPTIONS,GET,HEAD,PUT,DELETE,MOVE,PROPFIND,PROPPATCH,MKCOL, ETags andIf-Match/If-None-Match - WebDAV ACL subset (RFC 3744): principals,
current-user-privilege-set,owner,principal-property-search,principal-search-property-set,principal-match - CalDAV (RFC 4791):
MKCALENDAR,calendar-query(comp/prop/param filters, text-match, time-range incl. recurrence and alarms),calendar-multiget,free-busy-query,supported-calendar-component-set, UID conflict detection - CardDAV (RFC 6352):
addressbook-query(anyof/allof, match types, result limits),addressbook-multiget - Extended MKCOL (RFC 5689), collection synchronization
sync-collection(RFC 6578),expand-property(RFC 3253), CalendarServergetctag, Apple calendar colour/order - Recurrence:
RRULE(all frequencies,BYxxx,BYSETPOS,COUNT/UNTIL),RDATE,EXDATE,RECURRENCE-IDoverrides; time zones via IANA/Windows IDs or embeddedVTIMEZONE - Scheduling: RFC 6638 implicit scheduling (
calendar-auto-schedule, inbox/outbox,Schedule-Tag, outbox free/busy), iMIP (RFC 6047) over SMTP (STARTTLS/TLS, AUTH PLAIN/LOGIN) - Calendar sharing from clients: draft-pot-webdav-resource-sharing and CalendarServer
CS:share - CalendarServer
calendar-proxydelegation, group principals, quotas (RFC 4331), vCard version negotiation (RFC 6352 §5.1.1 / §8.6) - WebDAV-Push:
push-register,transports/topicproperties, Web Push (RFC 8030) with aes128gcm message encryption (RFC 8291) and VAPID (RFC 8292) - SyncML Data Synchronization 1.1/1.2 (XML and WBXML), IMAP4rev1 login (RFC 3501, STARTTLS, SASL PLAIN), Mozilla autoconfig 1.1, Microsoft Autodiscover POX/v2
- Migration client: RFC 6764 discovery,
current-user-principal/ home sets, multiget with GET fallback, Basic and Digest authentication
Not implemented: sharing address books from clients, sharing invitations that sharees accept or decline (shares apply at once), processing iMIP replies that arrive by email, calendar-data partial retrieval/expand, managed attachments, time zones by reference, JMAP, LOCK/UNLOCK, COPY, Digest authentication for clients connecting to this server. In SyncML: MD5 credentials (passwords are not stored in a reversible form), large-object (MoreData) splitting, server-alerted sync, filters and hierarchical sync.
.gitea/workflows/CalDav.yml runs on Gitea Actions for pushes to main, v* tags, pull requests and manual runs:
docker build --target test .runs the whole test suite inside the SDK image (the runner needs no .NET).- Builds the image (version from the tag, otherwise
0.0.0-ci.<sha>) and smoke-tests it:/health, the first-run setup page, and the DAV endpoints being gated until setup. - Except for pull requests, pushes
git.nullify.net/simon/nullify-caldav:<sha>;mainalso pushes:latest, and av1.2.3tag pushes:1.2.3,:1.2and:latest. - On
mainand tags, calls the Portainer webhook if one is configured.
Repository secrets: GITEADOCKERTOKEN (token with package write for the registry user) and, optionally, CALDAV_PORTAINER_WEBHOOK. Forks should change IMAGE and the registry login in the workflow. To run the image from the registry, replace build: . in docker-compose.yml with image: git.nullify.net/simon/nullify-caldav:latest.
dotnet test # unit + integration tests
dotnet tool restore
dotnet ef migrations add <Name> -p src/Nullify.CalDav -o Data/MigrationsMigrations are applied automatically on start-up.
src/Nullify.CalDav
├── Dav/ WebDAV/CalDAV/CardDAV protocol: resolver, properties, filters, methods, reports
├── SyncMl/ SyncML session engine and WBXML codec
├── Formats/ iCalendar/vCard parser & writer, recurrence engine, time zones, legacy format conversion
├── Data/ EF Core entities, DbContext, migrations (SQLite)
├── Services/ Stores, users, credentials, IMAP, SMTP, scheduling, push, trash/quota, migration,
│ subscriptions, birthdays, directory, settings, OIDC, autoconfiguration
└── Pages/ Razor Pages management UI
Only Microsoft packages are referenced: ASP.NET Core, Microsoft.EntityFrameworkCore.Sqlite and Microsoft.AspNetCore.Authentication.OpenIdConnect. The SQLite provider brings in the native SQLite bundle (SQLitePCLRaw) transitively, which is required to talk to SQLite at all. The IMAP and SMTP clients, Web Push encryption, WBXML codec and format converters are implemented in this project.
Copyright 2026 Simon Soanes.
Licensed under the Apache License, Version 2.0. See NOTICE for attribution and third-party components. Unless required by applicable law or agreed to in writing, the software is distributed on an "AS IS" basis, without warranties or conditions of any kind.