- Python 97.9%
- Nix 1.3%
- Dockerfile 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The detect-private-key hook flags it, correctly: better to redact the illustration than to exempt docs/ from the check. |
||
| .github/workflows | ||
| docs | ||
| src/caldavsync | ||
| tests | ||
| tools | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| .python-version | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| compose.yaml | ||
| Dockerfile | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| NOTICE | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
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
caldavandicalendarfor the CalDAV sidegoogle-api-python-clientwith service account credentials for Googlepydantic-settingsfor boot-time configuration validation- SQLite for sync state,
structlogfor 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.
- Google Cloud console -> create or pick a project.
- APIs & Services -> Library -> enable Google Calendar API.
- IAM & Admin -> Service Accounts -> create one.
- On that account: Keys -> Add key -> Create new key -> JSON. Download it.
- Google Calendar -> Settings -> your calendar -> Share with specific people
or groups -> add the service account's
client_emailaddress 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=trueturns 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_DAYSare 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
- docs/installation.md - prerequisites, the Google setup in full
- docs/development.md - workflow, the mixed Docker/host combinations, tests
- docs/deployment.md - NixOS
oci-container, sops-nix, backup and restore - docs/architecture.md - how a run works, and why it is shaped this way
- docs/decisions/ - the decisions that will be questioned later
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.