CalCard is a high-performance CalDAV and CardDAV server written in Go. It provides synchronization for calendars (RFC 4791) and contacts (RFC 6352) across various clients like iOS, Android (via DAVx5), Thunderbird, and more.
⚠️ Status and disclaimer: CalCard is provided as is, used at your own risk, with no warranty. It stores personal data, so keep regular backups, and back up before every upgrade, since some migrations can't be undone. Don't expose it to the internet without HTTPS.
- Standard Protocols: CalDAV (RFC 4791) and CardDAV (RFC 6352) synchronization, including WebDAV-Sync (RFC 6578).
- Modern Authentication: JWT-based auth and OAuth2/OIDC support (Google, Microsoft, 3rd Party).
- Sharing: Calendar and Address Book sharing between users.
- Public Calendars: Ability to publish calendars via unique URLs.
- Security Focused: Built-in TLS support, Rate Limiting, CORS, and Security Headers.
- Flexible Storage: Supports SQLite (default) and PostgreSQL.
Scope note: CalCard is a personal sync server. It does not implement CalDAV scheduling (RFC 6638) — no attendee invitations (iTIP/iMIP), no
schedule-inbox/schedule-outbox, and no free-busy. Events withATTENDEEproperties sync verbatim but generate no invitations. The server advertises onlycalendar-access, nevercalendar-auto-schedule.
| Dashboard | Dashboard (dark) |
|---|---|
![]() |
![]() |
| Calendar | Contacts |
|---|---|
![]() |
![]() |
- Go 1.25+ or Docker
- A secure JWT secret (at least 32 characters)
The easiest way to get started is Docker Compose with the published image
herrj/calcard. Two example compose
files live in docker-compose/ — copy one and adjust it:
| File | Database |
|---|---|
docker-compose/docker-compose.yml |
SQLite (data in the /data volume) |
docker-compose/docker-compose.postgres.yml |
PostgreSQL 18 (needs POSTGRES_PASSWORD) |
cd docker-compose
docker compose up -d # SQLite
# docker compose -f docker-compose.postgres.yml up -d # PostgreSQLBoth files pull herrj/calcard:latest; pin a version (e.g. herrj/calcard:1.0.0)
for anything you rely on. Each one also has a commented build: block for
building from source instead.
This is one image that serves everything on one origin (port 8080):
the REST API (/api), DAV (/dav), /.well-known, /health, and the built
web UI. Open http://localhost:8080/ and the UI loads from the same
server that answers its API calls. CALDAV_BASE_URL is the address clients
actually use (e.g. http://<nas-ip>:8751 or https://cal.example.com). Left
empty, the URLs the server hands out (the DAV server URL for an app password,
public calendar links) follow the address each request came in on; set it
anyway once you configure SMTP or OAuth, which require it. Behind a TLS-terminating
reverse proxy either set it or, if the proxy overwrites X-Forwarded-Proto /
X-Forwarded-Host, set CALDAV_TRUST_FORWARDED_HEADERS=true.
First run: open the UI — on an empty server it redirects to Initial Setup,
where you create the first account. That account is the administrator
(is_admin) and is signed in straight away. Setup works exactly once: after any
account has ever existed (deleted ones count), POST /api/v1/auth/setup answers 409. Further users register
via /auth/register (unless CALDAV_REGISTRATION_DISABLED=true).
Exposed to the internet? Set
CALDAV_SETUP_TOKEN. Until the first account exists, anyone who can reach the server can create the administrator, or register as a plain user, which closes setup for good. WithCALDAV_SETUP_TOKEN=<long random string>the setup page asks for that token and registration stays locked until setup is done. Left empty (the default), nothing is asked and registration is open: fine on a private network, but finish setup before exposing the server.
The container does not run as root. It runs as uid/gid
1000. A bind-mounted data directory (e.g./volume1/docker/calcard/data:/dataon a NAS) must be writable by that user, otherwise the server stops at startup withFailed to initialize database: … unable to open database file: no such file or directory. Eitherchown -R 1000:1000the host folder, or set the user and group ID in the compose file to the folder's owner (find them withls -ln):services: caldav: user: "1026:100" # <uid>:<gid>Named Docker volumes (the default in the example files) need no setup.
Because the UI and API share an origin, the browser never applies CORS, so
no CORS configuration is required. The CORS settings
(CALDAV_CORS_ENABLED, …) exist only as an escape hatch for split hosting (UI
and API on separate origins); leave them disabled for the single-container setup.
The SPA is baked at image-build time as a static bundle. Do not set
NUXT_PUBLIC_API_BASE_URLfor the single-container image — an empty value makes the UI issue same-origin relative requests. Set it only when hosting the UI on a different origin than the API.
The release image is multi-arch (linux/amd64 + linux/arm64) and published as
herrj/calcard. Run from anywhere — the script switches to the repo root itself:
docker login # once, for the push
server/scripts/docker-release.sh # version = git tag on HEAD, "v" stripped
server/scripts/docker-release.sh 1.0.0 # or give the version explicitly
server/scripts/docker-release.sh 1.0.0 --no-push # build both platforms, don't pushStable versions are also tagged latest; pre-releases (a version with a - suffix) never are.
The arm64 image is built under QEMU, so expect it to be slow. A single-platform
local build is just docker build -f server/Dockerfile -t calcard . from the
repo root.
Debugging DAV clients: set CALDAV_LOG_PAYLOADS=true to log every request's
headers and /dav bodies (credentials redacted) — see CONFIGURATION.md.
e2e/ is a Python (pytest + httpx) suite that tests a running
server over HTTP only (REST, CalDAV/CardDAV, MCP): in e2e/ install
requirements.txt into a venv, cp .env.example .env, set CALCARD_URL, then
./run. It registers throwaway users (self-registration must be enabled) and
removes what it created.
Back up the database before you upgrade. On start the server migrates the
schema and then runs data migrations (database.auto_migrate, on by default;
./server migrate runs them by hand). Most only repair data, but two
permanently delete rows nobody can reach any more:
- the data of accounts deleted with older versions (their calendars, address books, shares and credentials), and
- the remains of calendars and address books deleted with older versions. Deleting a collection used to hide only the collection itself; its events, contacts, photos, sync history and shares stayed in the database. Deleting a calendar or address book now removes everything in it, and the first start of the new version purges what earlier deletes left behind.
Their owners asked for this data to be gone, but once the migration has run it
cannot be restored. With SQLite, stop the server and copy caldav.db (plus
any caldav.db-wal / caldav.db-shm) out of the data directory; with
PostgreSQL, pg_dump the database. The server logs what the purge removed as
a WARN data migration: permanently deleted … line with a row count per
table; a start with nothing to purge logs nothing.
- Clone the repository
- Navigate to the server directory:
cd server - Copy the example configuration:
cp configs/config.yaml.example config.yaml - Update
config.yamlwith your settings (don't forget the JWT secret) - Run the server:
go run ./cmd/server
For frontend development, run the SPA separately with cd webinterface && pnpm dev
(serves on :3000 against the API on :8080); in that mode the UI defaults
apiBaseUrl to http://localhost:8080.
- Configuration Reference: CONFIGURATION.md - Detailed guide for all YAML and Environment parameters.
- Example Configuration: server/configs/config.yaml.example - A complete example YAML file.
- Architecture: server/CLAUDE.md and the per-layer
CLAUDE.md/AGENT.mdfiles underserver/internal/- layering, request flow, and conventions. (These replaced the former root-levelTechnical Overview.md.) - Feature status: STORIES.md - what is implemented, what remains.
The server exposes a Model Context Protocol endpoint at /mcp,
so an AI assistant can read and manage calendars and contacts on your behalf: list and search
events and contacts, create/update/delete them, find free slots across your calendars, and
create, rename or delete the calendars and address books themselves (your own only — a collection
shared with you stays the owner's to manage). Deleting a calendar or address book is permanent and
takes everything in it, so assistants are told to ask you first.
-
Sign in to the web UI and go to Settings → MCP Access.
-
Create a token. It is shown once — the server stores only a hash.
-
Point your client at the endpoint:
claude mcp add --transport http calcard https://caldav.example.com/mcp \ --header "Authorization: Bearer calcard_mcp_..."
A token grants exactly what the account can see itself, including collections shared with it, and
is revoked from the same page (revocation takes effect on the next request). Operators who do not
want an AI tool surface at all can remove the routes entirely with CALDAV_MCP_ENABLED=false — see
CONFIGURATION.md.
Follow a calendar published elsewhere — a holiday calendar, a sports schedule, an export from another service. The server fetches the feed on a schedule and mirrors it into a calendar of your own, which then syncs to every CalDAV client like any other.
- Sign in and go to Settings → Subscriptions.
- Paste the feed URL (an
.icslink;webcal://links work too) and pick how often to refresh — 15m, 30m, 1h (default), 6h, 12h or 24h. - The feed is fetched immediately, so a bad URL is reported there and then rather than surfacing as a silently empty calendar an hour later.
Subscribed calendars are read-only, to their owner as much as to anyone they are shared with: the next refresh replaces their contents wholesale, so an event added locally would disappear without a trace. You can still rename, recolour, share and delete the calendar itself. A feed that stops responding is retried with exponential backoff and, after five consecutive failures, pauses itself with the reason shown on the settings page; refreshing manually resumes it.
Because the server fetches URLs its users choose, feeds must be https:// and on publicly
routable addresses by default. Self-hosted deployments whose users subscribe to feeds on the same
private network can relax both — see CONFIGURATION.md.
There is no generated API reference. The REST surface is documented in
server/CLAUDE.md (endpoint areas) and, per endpoint, by the
// METHOD /api/v1/... doc comment above each handler in
server/internal/adapter/http/.
The project includes a modern web interface built with Nuxt 4.
- Node.js 26+
- pnpm (Recommended)
- Navigate to the web interface directory:
cd webinterface - Install dependencies:
pnpm install
- Run in development mode:
The interface will be available at
pnpm run dev
http://localhost:3000.
To create a production-optimized build:
- Build the project:
pnpm run build
- Preview the production build:
pnpm run preview




