No description
  • Python 97.9%
  • Nix 1.3%
  • Dockerfile 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
m4rti e39b610eab
Some checks failed
ci / quality (push) Has been cancelled
ci / test (push) Has been cancelled
ci / image (push) Has been cancelled
ci / audit (push) Has been cancelled
ci / nix (push) Has been cancelled
docs: elide the PEM header in the service account example
The detect-private-key hook flags it, correctly: better to redact the
illustration than to exempt docs/ from the check.
2026-09-04 11:48:01 +02:00
.github/workflows docs: README, installation, development, deployment, architecture, ADRs, AGENTS.md 2026-09-04 11:34:56 +02:00
docs docs: elide the PEM header in the service account example 2026-09-04 11:48:01 +02:00
src/caldavsync feat: model, preserve and optionally sync VALARM reminders 2026-09-04 11:47:26 +02:00
tests feat: model, preserve and optionally sync VALARM reminders 2026-09-04 11:47:26 +02:00
tools feat: Nix flake, CI, pre-commit hooks; test the Google client and its request bodies 2026-09-04 11:28:21 +02:00
.dockerignore feat: Docker image, compose with test CalDAV server, content-hash change markers 2026-09-04 11:20:08 +02:00
.editorconfig chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
.env.example feat: model, preserve and optionally sync VALARM reminders 2026-09-04 11:47:26 +02:00
.gitattributes chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
.gitignore chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
.pre-commit-config.yaml feat: Nix flake, CI, pre-commit hooks; test the Google client and its request bodies 2026-09-04 11:28:21 +02:00
.python-version chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
AGENTS.md feat: model, preserve and optionally sync VALARM reminders 2026-09-04 11:47:26 +02:00
CLAUDE.md docs: README, installation, development, deployment, architecture, ADRs, AGENTS.md 2026-09-04 11:34:56 +02:00
compose.yaml feat: Nix flake, CI, pre-commit hooks; test the Google client and its request bodies 2026-09-04 11:28:21 +02:00
Dockerfile feat: Nix flake, CI, pre-commit hooks; test the Google client and its request bodies 2026-09-04 11:28:21 +02:00
flake.lock docs: README, installation, development, deployment, architecture, ADRs, AGENTS.md 2026-09-04 11:34:56 +02:00
flake.nix feat: Nix flake, CI, pre-commit hooks; test the Google client and its request bodies 2026-09-04 11:28:21 +02:00
LICENSE chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
NOTICE chore: repo skeleton, license, tooling config 2026-09-04 10:48:27 +02:00
pyproject.toml docs: README, installation, development, deployment, architecture, ADRs, AGENTS.md 2026-09-04 11:34:56 +02:00
README.md feat: model, preserve and optionally sync VALARM reminders 2026-09-04 11:47:26 +02:00
uv.lock docs: README, installation, development, deployment, architecture, ADRs, AGENTS.md 2026-09-04 11:34:56 +02:00

caldavsync

Bidirectional sync between one Google Calendar and one CalDAV calendar (Nextcloud, Radicale, Baikal, SOGo), configured entirely by environment variables. Fill in a .env, run docker compose up -d, done: no config file, no interactive setup, no credential mounted into the container.

Status: works, and covered by 257 tests including a suite against a real CalDAV server. Not yet run against a production calendar for a long stretch, so read Known limitations before pointing it at a calendar you care about, and take the DRY_RUN=true step in the quickstart seriously.

Stack

  • Python 3.13, uv
  • caldav and icalendar for the CalDAV side
  • google-api-python-client with service account credentials for Google
  • pydantic-settings for boot-time configuration validation
  • SQLite for sync state, structlog for JSON logs
  • Docker as the deploy target; Nix flake for development and a NixOS build

What it syncs

Direction
Create, edit, delete an event both ways
All-day and timed events, with timezones both ways
Recurring series (RRULE, RDATE) both ways
A single moved or edited occurrence (RECURRENCE-ID) both ways
A single deleted occurrence (EXDATE / cancelled instance) both ways
Attendees and their response status both ways
Organizer, location, description both ways
Colours (Google's 11 map to CSS names) both ways
Reminders (VALARM) preserved on CalDAV always; to Google with SYNC_REMINDERS=true
Categories CalDAV only; preserved, never clobbered

Conflicts, when both sides changed since the last run, resolve by newest_wins (default), google_wins or caldav_wins.

Quickstart

1. Google, once

Google Calendar has no credential that is both writable and non-interactive except a service account, so that is what this uses. An API key cannot work: API keys are read-only and reach only public calendars.

  1. Google Cloud console -> create or pick a project.
  2. APIs & Services -> Library -> enable Google Calendar API.
  3. IAM & Admin -> Service Accounts -> create one.
  4. On that account: Keys -> Add key -> Create new key -> JSON. Download it.
  5. Google Calendar -> Settings -> your calendar -> Share with specific people or groups -> add the service account's client_email address and grant it Make changes to events.

Step 5 is the one people miss. caldavsync check prints the exact address.

2. Configure

cp .env.example .env
$EDITOR .env

Six values are required: GOOGLE_SERVICE_ACCOUNT_JSON, GOOGLE_CALENDAR_ID, CALDAV_URL, CALDAV_USER, CALDAV_PASS, CALDAV_CALENDAR_ID. Everything else has a default.

The key goes in on one line, most easily base64-encoded:

echo "GOOGLE_SERVICE_ACCOUNT_JSON=$(base64 -w0 service-account.json)" >> .env

3. Docker

docker compose run --rm caldavsync check     # verifies both sides, explains failures
DRY_RUN=true docker compose up               # reports intended writes, changes nothing
docker compose up -d                         # for real
docker compose logs -f
curl localhost:8080/status

4. Local tooling, without Docker

Needs uv and nothing else - it fetches its own Python:

uv sync --frozen
STATE_DB=./data/state.db LOG_FORMAT=console uv run caldavsync check
STATE_DB=./data/state.db LOG_FORMAT=console uv run caldavsync once

5. Nix

nix develop            # toolchain, and STATE_DB/LOG_FORMAT preset for a dev host
uv sync --frozen
uv run caldavsync once

nix build .#caldavsync # the application
nix build .#container  # an image, no Docker daemon needed: docker load < result

Environment

Variable Default What it is
GOOGLE_SERVICE_ACCOUNT_JSON required Service account key: raw JSON or base64
GOOGLE_CALENDAR_ID required Real calendar id, e.g. your email. Not primary
CALDAV_URL required DAV root, or the calendar collection URL itself
CALDAV_USER required CalDAV username
CALDAV_PASS required App password, not an account password
CALDAV_CALENDAR_ID required Collection id. Created if absent
CALDAV_CALENDAR_NAME = calendar id Display name, used only at creation
SYNC_DIRECTION bidirectional google_to_caldav, caldav_to_google
SYNC_CONFLICT_RESOLUTION newest_wins google_wins, caldav_wins
SYNC_INTERVAL_SECONDS 300 Daemon interval
SYNC_PAST_DAYS / SYNC_FUTURE_DAYS 30 / 365 Reconciliation window
SYNC_DELETE_PROPAGATION true Mirror deletions
SYNC_SEND_INVITATIONS false Must stay false under service account auth
SYNC_REMINDERS false Map reminders to Google too. Off by default; see Known limitations
DRY_RUN false Report intended writes, change nothing
STATE_DB /data/state.db Sync state. Needs a volume
HEALTH_HOST / HEALTH_PORT 0.0.0.0 / 8080 Health server, inside the container
CALDAVSYNC_HOST_PORT 8080 Where compose publishes it on the host
HEALTH_FAILURE_THRESHOLD 3 Failed runs before /readyz goes unready
LOG_LEVEL / LOG_FORMAT INFO / json console for humans
TZ UTC Container timezone

Every secret also accepts a <NAME>_FILE variant naming a file that holds the value, for sops-nix, agenix or Docker secrets. See .env.example, which documents all of it with the setup steps inline.

Commands

Command What it does
caldavsync run Sync every SYNC_INTERVAL_SECONDS. The container default
caldavsync once Sync once, exit non-zero on any failure
caldavsync once --dry-run Report intended writes, change nothing
caldavsync check Verify config and both calendars, explain what is wrong

Exit codes: 0 success, 1 runtime failure, 2 the environment does not describe a runnable configuration (retrying will not help).

Task Command
Test uv run pytest
Unit tests only uv run pytest -m "not integration"
Lint uv run ruff check
Format uv run ruff format .
Typecheck uv run mypy
Real CalDAV server docker compose --profile test up -d radicale

Endpoints

Route Meaning
/healthz The process is up and serving. Liveness
/readyz Both calendars authenticated and one sync succeeded; unready after HEALTH_FAILURE_THRESHOLD consecutive failures
/status Last run: when, what changed, what failed, how many series are linked

Layout

src/caldavsync/     the application; see docs/architecture.md
tests/              unit tests, plus integration tests against real Radicale
tests/radicale/     the throwaway CalDAV server those use
docs/               installation, development, deployment, architecture, decisions
tools/              repo checks not worth a dependency

Known limitations

  • Reminders do not reach Google by default. They are modelled, preserved and round-tripped on the CalDAV side, so a Google-side edit no longer destroys them. But Google reports an event's reminders for the authenticated user, which under service account auth is the service account and not you - so what it reports is not your reminders, and what it accepts would notify nobody. SYNC_REMINDERS=true turns the mapping on anyway, with the lossy edges documented in docs/decisions/0004-reminders.md.
  • Attachments and X- properties are still dropped on rewrite. They are not in the model, so a Google-side edit rewrites the CalDAV resource without them. Reminders, categories and unmappable colours are explicitly preserved; these are not.
  • One calendar pair per container. Run a second container for a second pair; the state databases must not be shared.
  • The reconciliation window is not the whole calendar. Events outside SYNC_PAST_DAYS/SYNC_FUTURE_DAYS are left alone, not deleted.
  • A service account cannot email invitations on your behalf. Attendees sync; the notifications do not.
  • An occurrence moved outside the window cannot be synced as an override, and says so in the logs.

Docs

Credits

Reconciliation design and the colour/PARTSTAT tables derive from photoevents/caldavsync (MIT). See NOTICE for what is shared and what differs.

License

MIT. See LICENSE.