Tools: How to Self-Host Baikal with Docker Compose

Tools: How to Self-Host Baikal with Docker Compose

What Is Baikal?

Prerequisites

Docker Compose Configuration

SQLite Setup (Recommended for Most Users)

MariaDB Setup (Optional — For Large Deployments)

Initial Setup

Creating Users

Creating Additional Calendars and Address Books

Connecting Clients

iOS (Native Calendar and Contacts)

macOS Calendar and Contacts

Android (DAVx5)

Thunderbird

Configuration

CalDAV Auto-Discovery

Changing the Admin Password

File Permissions

Reverse Proxy

Backup

Backing Up Named Volumes

Restoring from Backup

MariaDB Backup

Troubleshooting

Calendar Not Syncing Across Devices

Permission Denied or 403 Errors

Web Wizard Doesn't Load on First Run

Upgrade Issues After Updating the Docker Image

iOS/macOS Shows "Cannot Verify Server Identity"

Verdict

Related Baikal is a lightweight CalDAV and CardDAV server that syncs your calendars and contacts across all your devices. It replaces Google Calendar, Google Contacts, iCloud Calendar, and iCloud Contacts with something you control entirely. Baikal is built on the sabre/dav framework (PHP), includes a clean web-based admin panel for managing users, calendars, and address books, and runs on SQLite or MariaDB. It uses minimal resources — under 50 MB of RAM — and handles multi-user setups without breaking a sweat. Baikal uses a community-maintained Docker image (ckulka/baikal) since there is no official image from the project. The nginx variant is recommended — it is lighter and faster than the Apache variant. SQLite is the default and works perfectly for personal use or small teams (under 50 users). No extra services needed. Create a docker-compose.yml file: If you expect many users or want a database you can query and back up independently, use MariaDB instead of SQLite. You configure the database during the web wizard — this Compose file just makes MariaDB available. Create a docker-compose.yml file: When you reach the database step in the web wizard, select MySQL and enter: Baikal uses a web wizard for initial configuration. There are no environment variables to set — everything happens through the browser. After setup, the admin panel is available at http://your-server-ip:8080/admin/. Each user automatically gets a default calendar and address book. Baikal's CalDAV and CardDAV URLs follow this pattern: The default calendar is named default and the default address book is named default. So for a user called john: Most clients support auto-discovery, so you only need to provide the base URL: For clients that support auto-discovery (iOS, macOS, DAVx5), configure your reverse proxy to handle .well-known URLs: This lets clients find the CalDAV/CardDAV server using just the domain name, without needing the full /dav.php path. Log into the admin panel at /admin/, navigate to Settings, and update the admin password. The nginx variant of the Docker image runs as UID 101 (the nginx user). If you use bind mounts instead of named volumes, ensure the host directories are owned by UID 101: With named volumes (as shown in the Compose examples above), Docker handles permissions automatically. Most CalDAV/CardDAV clients require HTTPS. Set up a reverse proxy with SSL termination in front of Baikal. Caddy (in your Caddyfile): See Reverse Proxy Setup for full configuration with other proxies. Baikal stores all its data in two volumes. Back up both: If using MariaDB instead of SQLite, dump the database separately: See Backup Strategy for a complete 3-2-1 backup approach. Symptom: Events created on one device do not appear on another, even after waiting several minutes.

Fix: Verify that both devices are pointed at the exact same CalDAV URL. The URL is case-sensitive and must include the correct calendar name. In DAVx5, open the account and tap the sync button. On iOS, pull down on the calendar list to force a refresh. Check Baikal logs for errors: If events sync one direction but not the other, the second client may be using a different calendar path. Symptom: Client returns 403 Forbidden when creating or modifying events/contacts.Fix: This usually means the client is trying to write to a calendar or address book that belongs to a different user. Verify the URL includes the correct username. Each user can only access their own collections unless you configure shared access through the admin panel. Also check file permissions if using bind mounts — the container needs write access as UID 101: Symptom: Accessing http://your-server-ip:8080 shows a blank page, a 500 error, or the Baikal dashboard instead of the setup wizard.Fix: The wizard only runs when the config volume is empty. If a previous failed setup left partial configuration files, the wizard won't appear. Remove the config volume and start fresh: If you see a 500 error, check that the baikal_config and baikal_data volumes are writable by the container. With bind mounts, ensure UID 101 owns the directories. Symptom: After pulling a new version of ckulka/baikal, the admin panel shows errors or calendar sync breaks.Fix: Baikal runs database migrations automatically on startup, but occasionally a migration can fail. Check the logs: If you see database migration errors, the safest path is: Pin your image tag (as shown in the Compose examples) so upgrades only happen when you explicitly change the tag. Symptom: Apple devices refuse to connect and show certificate warnings.

Fix: Apple is strict about SSL certificates. Self-signed certificates will not work. You need a valid certificate from Let's Encrypt or another CA. Set up a reverse proxy with automatic SSL (see the Reverse Proxy section above). Also ensure your .well-known redirects are configured — Apple clients rely on these for CalDAV/CardDAV discovery. Baikal is one of the lightest self-hosted services you can run. It fits comfortably on a Raspberry Pi alongside a dozen other containers. Baikal is the best CalDAV/CardDAV server for most people who want calendar and contact sync without the bloat. It has a clean admin panel for managing users and collections, supports both SQLite and MariaDB, works with every major CalDAV/CardDAV client, and uses almost no resources. For a household or small team that just needs reliable calendar and contact sync, Baikal is the right choice. If you want an even more minimal setup and don't need a web admin panel, Radicale stores data as flat files and uses even less memory. If you need file sync, office collaboration, or other groupware features alongside calendars and contacts, Nextcloud is the better (but heavier) option. For pure CalDAV/CardDAV, Baikal hits the sweet spot between simplicity and usability. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ services: baikal: image: ckulka/baikal:0.10.1-nginx container_name: baikal -weight: 500;">restart: unless-stopped ports: - "8080:80" volumes: # Stores admin settings, database config, and encryption keys - baikal_config:/var/www/baikal/config # Stores the SQLite database and application-specific data - baikal_data:/var/www/baikal/Specific volumes: baikal_config: baikal_data: services: baikal: image: ckulka/baikal:0.10.1-nginx container_name: baikal -weight: 500;">restart: unless-stopped ports: - "8080:80" volumes: # Stores admin settings, database config, and encryption keys - baikal_config:/var/www/baikal/config # Stores the SQLite database and application-specific data - baikal_data:/var/www/baikal/Specific volumes: baikal_config: baikal_data: services: baikal: image: ckulka/baikal:0.10.1-nginx container_name: baikal -weight: 500;">restart: unless-stopped ports: - "8080:80" volumes: # Stores admin settings, database config, and encryption keys - baikal_config:/var/www/baikal/config # Stores the SQLite database and application-specific data - baikal_data:/var/www/baikal/Specific volumes: baikal_config: baikal_data: -weight: 500;">docker compose up -d -weight: 500;">docker compose up -d -weight: 500;">docker compose up -d services: baikal: image: ckulka/baikal:0.10.1-nginx container_name: baikal -weight: 500;">restart: unless-stopped ports: - "8080:80" volumes: - baikal_config:/var/www/baikal/config - baikal_data:/var/www/baikal/Specific depends_on: mariadb: condition: service_healthy mariadb: image: mariadb:11.7.2 container_name: baikal-db -weight: 500;">restart: unless-stopped environment: # Change these values before first run MYSQL_ROOT_PASSWORD: change-this-root-password MYSQL_DATABASE: baikal MYSQL_USER: baikal MYSQL_PASSWORD: change-this-baikal-password volumes: - mariadb_data:/var/lib/mysql healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s timeout: 5s retries: 5 volumes: baikal_config: baikal_data: mariadb_data: services: baikal: image: ckulka/baikal:0.10.1-nginx container_name: baikal -weight: 500;">restart: unless-stopped ports: - "8080:80" volumes: - baikal_config:/var/www/baikal/config - baikal_data:/var/www/baikal/Specific depends_on: mariadb: condition: service_healthy mariadb: image: mariadb:11.7.2 container_name: baikal-db -weight: 500;">restart: unless-stopped environment: # Change these values before first run MYSQL_ROOT_PASSWORD: change-this-root-password MYSQL_DATABASE: baikal MYSQL_USER: baikal MYSQL_PASSWORD: change-this-baikal-password volumes: - mariadb_data:/var/lib/mysql healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s timeout: 5s retries: 5 volumes: baikal_config: baikal_data: mariadb_data: services: baikal: image: ckulka/baikal:0.10.1-nginx container_name: baikal -weight: 500;">restart: unless-stopped ports: - "8080:80" volumes: - baikal_config:/var/www/baikal/config - baikal_data:/var/www/baikal/Specific depends_on: mariadb: condition: service_healthy mariadb: image: mariadb:11.7.2 container_name: baikal-db -weight: 500;">restart: unless-stopped environment: # Change these values before first run MYSQL_ROOT_PASSWORD: change-this-root-password MYSQL_DATABASE: baikal MYSQL_USER: baikal MYSQL_PASSWORD: change-this-baikal-password volumes: - mariadb_data:/var/lib/mysql healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s timeout: 5s retries: 5 volumes: baikal_config: baikal_data: mariadb_data: -weight: 500;">docker compose up -d -weight: 500;">docker compose up -d -weight: 500;">docker compose up -d CalDAV: https://your-domain/dav.php/calendars/USERNAME/CALENDAR-NAME/ CardDAV: https://your-domain/dav.php/addressbooks/USERNAME/ADDRESS-BOOK-NAME/ CalDAV: https://your-domain/dav.php/calendars/USERNAME/CALENDAR-NAME/ CardDAV: https://your-domain/dav.php/addressbooks/USERNAME/ADDRESS-BOOK-NAME/ CalDAV: https://your-domain/dav.php/calendars/USERNAME/CALENDAR-NAME/ CardDAV: https://your-domain/dav.php/addressbooks/USERNAME/ADDRESS-BOOK-NAME/ CalDAV: https://your-domain/dav.php/calendars/john/default/ CardDAV: https://your-domain/dav.php/addressbooks/john/default/ CalDAV: https://your-domain/dav.php/calendars/john/default/ CardDAV: https://your-domain/dav.php/addressbooks/john/default/ CalDAV: https://your-domain/dav.php/calendars/john/default/ CardDAV: https://your-domain/dav.php/addressbooks/john/default/ https://your-domain/dav.php https://your-domain/dav.php https://your-domain/dav.php /.well-known/caldav → redirect to /dav.php /.well-known/carddav → redirect to /dav.php /.well-known/caldav → redirect to /dav.php /.well-known/carddav → redirect to /dav.php /.well-known/caldav → redirect to /dav.php /.well-known/carddav → redirect to /dav.php mkdir -p ./config ./data chown -R 101:101 ./config ./data mkdir -p ./config ./data chown -R 101:101 ./config ./data mkdir -p ./config ./data chown -R 101:101 ./config ./data cal.yourdomain.com { redir /.well-known/caldav /dav.php 301 redir /.well-known/carddav /dav.php 301 reverse_proxy baikal:80 } cal.yourdomain.com { redir /.well-known/caldav /dav.php 301 redir /.well-known/carddav /dav.php 301 reverse_proxy baikal:80 } cal.yourdomain.com { redir /.well-known/caldav /dav.php 301 redir /.well-known/carddav /dav.php 301 reverse_proxy baikal:80 } # Stop Baikal to ensure data consistency (optional but recommended for SQLite) -weight: 500;">docker compose -weight: 500;">stop baikal # Back up both volumes -weight: 500;">docker run --rm \ -v baikal_config:/source/config:ro \ -v baikal_data:/source/data:ro \ -v $(pwd):/backup \ alpine tar czf /backup/baikal-backup-$(date +%Y%m%d).tar.gz -C /source . # Restart Baikal -weight: 500;">docker compose -weight: 500;">start baikal # Stop Baikal to ensure data consistency (optional but recommended for SQLite) -weight: 500;">docker compose -weight: 500;">stop baikal # Back up both volumes -weight: 500;">docker run --rm \ -v baikal_config:/source/config:ro \ -v baikal_data:/source/data:ro \ -v $(pwd):/backup \ alpine tar czf /backup/baikal-backup-$(date +%Y%m%d).tar.gz -C /source . # Restart Baikal -weight: 500;">docker compose -weight: 500;">start baikal # Stop Baikal to ensure data consistency (optional but recommended for SQLite) -weight: 500;">docker compose -weight: 500;">stop baikal # Back up both volumes -weight: 500;">docker run --rm \ -v baikal_config:/source/config:ro \ -v baikal_data:/source/data:ro \ -v $(pwd):/backup \ alpine tar czf /backup/baikal-backup-$(date +%Y%m%d).tar.gz -C /source . # Restart Baikal -weight: 500;">docker compose -weight: 500;">start baikal -weight: 500;">docker compose down -weight: 500;">docker run --rm \ -v baikal_config:/target/config \ -v baikal_data:/target/data \ -v $(pwd):/backup:ro \ alpine sh -c "cd /target && tar xzf /backup/baikal-backup-YYYYMMDD.tar.gz" -weight: 500;">docker compose up -d -weight: 500;">docker compose down -weight: 500;">docker run --rm \ -v baikal_config:/target/config \ -v baikal_data:/target/data \ -v $(pwd):/backup:ro \ alpine sh -c "cd /target && tar xzf /backup/baikal-backup-YYYYMMDD.tar.gz" -weight: 500;">docker compose up -d -weight: 500;">docker compose down -weight: 500;">docker run --rm \ -v baikal_config:/target/config \ -v baikal_data:/target/data \ -v $(pwd):/backup:ro \ alpine sh -c "cd /target && tar xzf /backup/baikal-backup-YYYYMMDD.tar.gz" -weight: 500;">docker compose up -d -weight: 500;">docker exec baikal-db mariadb-dump -u baikal -p'your-password' baikal > baikal-db-$(date +%Y%m%d).sql -weight: 500;">docker exec baikal-db mariadb-dump -u baikal -p'your-password' baikal > baikal-db-$(date +%Y%m%d).sql -weight: 500;">docker exec baikal-db mariadb-dump -u baikal -p'your-password' baikal > baikal-db-$(date +%Y%m%d).sql -weight: 500;">docker compose logs baikal -weight: 500;">docker compose logs baikal -weight: 500;">docker compose logs baikal chown -R 101:101 ./config ./data -weight: 500;">docker compose -weight: 500;">restart baikal chown -R 101:101 ./config ./data -weight: 500;">docker compose -weight: 500;">restart baikal chown -R 101:101 ./config ./data -weight: 500;">docker compose -weight: 500;">restart baikal -weight: 500;">docker compose down -weight: 500;">docker volume rm baikal_config -weight: 500;">docker compose up -d -weight: 500;">docker compose down -weight: 500;">docker volume rm baikal_config -weight: 500;">docker compose up -d -weight: 500;">docker compose down -weight: 500;">docker volume rm baikal_config -weight: 500;">docker compose up -d -weight: 500;">docker compose logs baikal -weight: 500;">docker compose logs baikal -weight: 500;">docker compose logs baikal - A Linux server (Ubuntu 22.04+ recommended) - Docker and Docker Compose installed (guide) - 256 MB of free RAM (Baikal itself uses under 50 MB, but PHP and nginx need headroom) - 100 MB of free disk space - A domain name (optional for local use, strongly recommended for remote access — most CalDAV clients require HTTPS) - Open http://your-server-ip:8080 in your browser - The setup wizard loads automatically on first run - Admin password — set a strong password for the admin panel. This is the only account that can manage users and collections. - Time zone — select your time zone from the dropdown. This affects how events are displayed in the admin panel. - Database — choose SQLite (default, recommended) or MySQL if you set up MariaDB above. For SQLite, no further configuration is needed. For MySQL, enter the connection details from the table above. - Click Save changes to complete setup - Go to the admin panel at /admin/ - Log in with the admin password you set during setup - Navigate to Users and resources - Click Add user - Enter a username (this becomes part of the CalDAV/CardDAV URL), display name, email, and password - In the admin panel, go to Users and resources - Click on a user - Click Add calendar or Add address book - Set the display name and (optionally) a description - Open Settings → Calendar → Accounts → Add Account → Other - Tap Add CalDAV Account - Server: your-domain (no port, no path — iOS discovers the rest) - Username: your Baikal username - Password: your Baikal password - Tap Next — iOS auto-discovers all calendars - Repeat for contacts: Settings → Contacts → Accounts → Add Account → Other → Add CardDAV Account with the same server, username, and password - Open Calendar → Settings → Accounts → Add Account → Other CalDAV Account - Account Type: Manual - Username: your Baikal username - Password: your Baikal password - Server Address: https://your-domain/dav.php - For contacts: Contacts → Settings → Accounts → Other Contacts Account → CardDAV with the same details - Install DAVx5 from F-Droid or the Play Store - Tap + to add an account - Select Login with URL and user name - Base URL: https://your-domain/dav.php - User name: your Baikal username - Password: your Baikal password - DAVx5 auto-discovers all calendars and address books — select which ones to sync - Enable sync in Android settings for the DAVx5 account - Open Calendar → New Calendar → On the Network - Username: your Baikal username - Location: https://your-domain/dav.php/calendars/USERNAME/default/ - Thunderbird prompts for your password, then discovers available calendars - For contacts: Install the CardBook add-on, then add a CardDAV address book with URL https://your-domain/dav.php/addressbooks/USERNAME/default/ - Scheme: http - Forward Hostname: baikal (or 127.0.0.1 if not on the same Docker network) - Forward Port: 80 (the container's internal port) - Enable SSL with Let's Encrypt - Add Custom Location for /.well-known/caldav — redirect (301) to /dav.php - Add Custom Location for /.well-known/carddav — redirect (301) to /dav.php - baikal_config — admin settings, database configuration, encryption keys - baikal_data — the SQLite database (if using SQLite), calendar data, and contact data - Stop Baikal: -weight: 500;">docker compose down - Back up both volumes (see Backup section above) - Start Baikal again: -weight: 500;">docker compose up -d - If the migration still fails, restore from your backup and wait for a patch release - RAM: 20-40 MB idle, under 50 MB under normal load - CPU: Negligible — CalDAV/CardDAV sync is not compute-intensive - Disk: ~50 MB for the application image, calendar and contact data is tiny (a few KB per entry, even heavy users rarely exceed 10 MB) - Baikal: CardDAV Contacts Not Syncing — Fix - Davis vs Baikal: Which Should You Self-Host? - How to Self-Host Radicale - How to Self-Host Nextcloud - Best Self-Hosted Calendar & Contacts - Replace Google Calendar - Radicale vs Baikal - Docker Compose Basics - Reverse Proxy Setup - Backup Strategy