Deployment guide

Baikal

Overview

Baikal is a lightweight, self-hosted CalDAV and CardDAV server built on the sabre/dav library. It syncs calendars, tasks and contacts across phones, laptops and mail clients using open standards: iOS and macOS Calendar and Contacts, Android with DAVx5, Thunderbird, Evolution and others. A small web admin console manages users, calendars and address books. Everything is stored in SQLite, so there is no database server to run.

This guide covers deploying and using Baikal 0.12.1 on Ubuntu 24.04 from SecureAnyCloud on Microsoft Azure.

What the SecureAnyCloud image adds:

  • HTTPS from the first boot. nginx serves Baikal on port 443 with a certificate generated on your VM's first boot. Port 80 redirects to HTTPS, and requests for a host name that the VM does not serve are dropped.
  • A password unique to your VM. The web admin and the CalDAV/CardDAV account admin share one password, generated on first boot and written to /root/baikal-credentials.txt (root only). The image ships with random password hashes that match no password. nginx and PHP-FPM refuse to start until first boot has finished.
  • No setup wizard. Upstream Baikal's /admin/install/ database step answers without a login until the installer is disabled. On this image the configuration is written in advance, the installer is disabled (Specific/INSTALL_DISABLED) and nginx returns 404 for /admin/install/.
  • The admin console security fix included. Baikal 0.12.0 and earlier let any signed-in CalDAV user take over the admin console by renaming a calendar or address book to HTML (advisory GHSA-j44x-cj7p-vx2w). Baikal 0.12.1 fixes this upstream, and the image ships 0.12.1 unmodified. Every build proves the fix. It renames a calendar to an HTML payload over CalDAV and checks that the admin pages show the name as text. It also checks that the same test does catch the payload when the escaping is switched off.
  • Hardened defaults. nginx and PHP-FPM are blocked from the Azure Instance Metadata Service and the Azure wire server. PHP is handed the configured host name and the real client address from the connection, never values a client sends. Cross-site form posts are refused.
  • A dedicated data disk. /var/www/baikal (code, configuration and the SQLite database) is its own 20 GiB Azure managed data disk that you can grow or snapshot independently.
  • Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.
  • 24/7 SecureAnyCloud support

What is on the image: Baikal 0.12.1 (the official release zip, checksum verified, sabre/dav 4.7.1), nginx 1.24, PHP 8.3 FPM with OPcache, SQLite, Ubuntu 24.04 LTS, cloud-init and the Azure Linux Agent. Build tools used only during the image build (such as unzip) are removed, and no compiler is installed.

Default user: the admin user you choose (examples use azureuser). Architecture: x64, Gen2. Recommended size: Standard_B2s.

Prerequisites

Before you deploy, make sure you have:

  • An Azure subscription, and a subscription to this offer in Azure Marketplace.
  • An SSH public key or a password for the VM's admin user.
  • A network security group that allows TCP 22 (SSH) from your admin network. It must also allow TCP 443 (Baikal) and TCP 80 (redirects to HTTPS) from the networks your calendar and contact clients use.
1

Deploy the virtual machine

In Azure Marketplace, search for Baikal, select the SecureAnyCloud offer and click Create.

On the Basics tab, choose your subscription, resource group and region, and name the VM. Choose Standard_B2s, because Baikal itself is light, and set the administrator account. Under Inbound port rules, allow SSH (22), HTTP (80) and HTTPS (443).

Keep the 20 GiB data disk on the Disks tab, because it holds Baikal and its data. Click Review + create, then Create.

To deploy with the Azure CLI instead, replace the values in angle brackets:

az vm image terms accept --urn sac1647283583153:baikal-ubuntu-24-04:default:latest
az vm create --resource-group <your-resource-group> --name baikal \
  --image sac1647283583153:baikal-ubuntu-24-04:default:latest \
  --size Standard_B2s --admin-username azureuser --ssh-key-values ~/.ssh/id_rsa.pub
az vm open-port --resource-group <your-resource-group> --name baikal --port 80,443 --priority 900

First boot takes well under a minute after the VM is running.

2

Connect to your VM

Connect over SSH with the admin user you chose:

ssh azureuser@<vm-public-ip>
3

Check the services

First boot generates the certificate and the password, and then nginx and PHP-FPM start. Run:

systemctl is-active nginx php8.3-fpm baikal-firstboot
sudo test -f /var/lib/sac/data/baikal-firstboot.done && echo FIRSTBOOT_DONE

All three units report active, and the first-boot marker is present:

active active active FIRSTBOOT_DONE

Only SSH and nginx listen on network ports. PHP-FPM uses a local socket:

sudo ss -lntp | awk 'NR>1 {print $4, $6}' | grep -v '127.0.0.5' | sort
0.0.0.0:22 users:(("sshd",...)) 0.0.0.0:443 users:(("nginx",...)) 0.0.0.0:80 users:(("nginx",...)) [::]:22 users:(("sshd",...)) [::]:443 users:(("nginx",...)) [::]:80 users:(("nginx",...))

Confirm that Baikal and its SQLite database live on the dedicated data disk:

df -h /var/www/baikal | tail -1
/dev/sdc 20G 12M 19G 1% /var/www/baikal
4

Get your URLs and password

Read the credentials file. Only root can read it:

sudo cat /root/baikal-credentials.txt

The file looks like this:

# Baikal 0.12.1 - generated on first boot by baikal-firstboot.service
# These credentials are unique to this instance; none was baked into the image. Keep this file secret.
# The same password signs in to the web admin and to CalDAV/CardDAV as user admin. baikal.admin.url=https://52.147.201.16/admin/ baikal.dav.url=https://52.147.201.16/dav.php/ baikal.admin.user=admin baikal.admin.pass=<BAIKAL_ADMIN_PASSWORD>
# The certificate is self signed (minted on this instance's first boot); port 80 redirects to https. # Change the name Baikal is served as:
sudo baikal-set-hostname <your-dns-name>
# User guide: https://secureanycloud.com/stacks/baikal/

The URLs use the VM's public IP address from the Azure Instance Metadata Service, or the private address if the VM has no public IP. The certificate names the public and private addresses.

Open the web admin at the address in baikal.admin.url. Its pages link to that configured name, so a sign-in started at another name is treated as cross-site and refused (HTTP 403). To serve Baikal under your own DNS name, see Step 9.

5

Sign in to the web admin

Open the baikal.admin.url in your browser. The certificate is self-signed, so your browser asks you to confirm it the first time. Alternatively, install your own certificate (Step 9).

Sign in as admin with the password from Step 4.

The dashboard shows the Baikal version, which services are on (web admin, CalDAV, CardDAV) and counts of users, calendars, events, address books and contacts. Your browser also asks GitHub for the latest Baikal release and may show a newer version there. The server itself makes no such call.

6

Users, calendars and address books

Open Users and resources. The image ships with one account, admin, with a default calendar and a default address book.

Use + Add user to create an account for each person who will sync. Each user signs in to CalDAV and CardDAV with the user name and password you set here.

Use Calendars and Address Books on each user row to manage that user's collections.

Open System Settings to control CalDAV and CardDAV, the time zone, the invite sender address, the authentication type and the web admin password.

7

A CalDAV and CardDAV round trip from the command line

The following block signs in with the VM's password over HTTPS and lists the admin calendars. It creates an event and a contact, reads them back and deletes them. It also shows that a wrong password is refused. The block passes the password to curl on standard input, so it never appears in a process list or your shell history:

sudo bash -c ' set -e F=/root/baikal-credentials.txt P=$(grep "^baikal.admin.pass=" $F | cut -d= -f2-) H=$(grep "^baikal.dav.url=" $F | cut -d= -f2- | sed -E "s#https://([^/]+)/.*#\1#") D="https://$H/dav.php"; R="--resolve $H:443:127.0.0.1" dav() { printf "user = \"%s\"\n" "$1" | curl -K - --digest -sk $R "${@:2}"; } want() { printf "%-16s %s\n" "$1" "$2"; [ "$2" = "$3" ] || { echo "FAIL: expected $3"; exit 1; }; } has() { case "$2" in *"$3"*) printf "%-16s %s\n" "$1" "$3";; *) echo "FAIL: $1"; exit 1;; esac; } T=$(mktemp -d); trap "rm -rf $T" EXIT printf "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//sac//guide//EN\r\nBEGIN:VEVENT\r\nUID:guide-event\r\nDTSTAMP:20261020T120000Z\r\nDTSTART:20261021T090000Z\r\nDTEND:20261021T100000Z\r\nSUMMARY:Guide test event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" > $T/guide-event.ics printf "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:guide-card\r\nFN:Guide Test Contact\r\nEND:VCARD\r\n" > $T/guide-card.vcf want "no login:" "HTTP $(curl -sk $R -o /dev/null -w %{http_code} -X PROPFIND -H "Depth: 0" $D/)" "HTTP 401" want "wrong password:" "HTTP $(dav "admin:not-the-password" -o /dev/null -w %{http_code} -X PROPFIND -H "Depth: 0" $D/)" "HTTP 401" has "calendar home:" "$(dav "admin:$P" -X PROPFIND -H "Depth: 1" $D/calendars/admin/)" "/dav.php/calendars/admin/default/" want "PUT event:" "HTTP $(dav "admin:$P" -X PUT -H "Content-Type: text/calendar" --data-binary @$T/guide-event.ics -o /dev/null -w %{http_code} $D/calendars/admin/default/guide-event.ics)" "HTTP 201" has "GET event:" "$(dav "admin:$P" $D/calendars/admin/default/guide-event.ics)" "SUMMARY:Guide test event" want "PUT contact:" "HTTP $(dav "admin:$P" -X PUT -H "Content-Type: text/vcard" --data-binary @$T/guide-card.vcf -o /dev/null -w %{http_code} $D/addressbooks/admin/default/guide-card.vcf)" "HTTP 201" has "GET contact:" "$(dav "admin:$P" $D/addressbooks/admin/default/guide-card.vcf)" "FN:Guide Test Contact" want "DELETE event:" "HTTP $(dav "admin:$P" -X DELETE -o /dev/null -w %{http_code} $D/calendars/admin/default/guide-event.ics)" "HTTP 204" want "DELETE contact:" "HTTP $(dav "admin:$P" -X DELETE -o /dev/null -w %{http_code} $D/addressbooks/admin/default/guide-card.vcf)" "HTTP 204" '
no login: HTTP 401 wrong password: HTTP 401 calendar home: /dav.php/calendars/admin/default/ PUT event: HTTP 201 GET event: SUMMARY:Guide test event PUT contact: HTTP 201 GET contact: FN:Guide Test Contact DELETE event: HTTP 204 DELETE contact: HTTP 204
8

Connect your calendar and contacts apps

Use the baikal.dav.url from Step 4 as the server address, together with the Baikal user name and password:

Client Where to add it Server address

iOS / iPadOS Settings, Calendar (or Contacts), Accounts, Add Account, Other, Add CalDAV (or CardDAV) Account <your-domain> or the public IP address

macOS Calendar (or Contacts), Settings, Accounts, +, Other CalDAV (CardDAV) Account, Manual https://<your-domain>/dav.php/

Android DAVx5, Login with URL and user name https://<your-domain>/dav.php/

Thunderbird New Calendar, On the Network https://<your-domain>/dav.php/

Clients that discover the server automatically use the well-known addresses, which redirect to the DAV endpoint:

H=$(sudo grep '^baikal.dav.url=' /root/baikal-credentials.txt | cut -d= -f2- | sed -E 's#https://([^/]+)/.*#\1#')
curl -sk -o /dev/null -w '.well-known/caldav -> %{http_code} %{redirect_url}\n' --resolve "$H:443:127.0.0.1" "https://$H/.well-known/caldav"
curl -sk -o /dev/null -w '.well-known/carddav -> %{http_code} %{redirect_url}\n' --resolve "$H:443:127.0.0.1" "https://$H/.well-known/carddav"
curl -s -o /dev/null -w 'http:// -> %{http_code} %{redirect_url}\n' --resolve "$H:80:127.0.0.1" "http://$H/admin/" [ "$(curl -sk -o /dev/null -w '%{redirect_url}' --resolve "$H:443:127.0.0.1" "https://$H/.well-known/caldav")" = "https://$H/dav.php/" ]
.well-known/caldav -> 301 https://52.147.201.16/dav.php/ .well-known/carddav -> 301 https://52.147.201.16/dav.php/ http:// -> 301 https://52.147.201.16/admin/

Most phones and desktop clients refuse a self-signed certificate for sync, or ask you to trust it once. For everyday use, give the VM a DNS name and a certificate that your devices trust (Step 9).

9

Your own DNS name and certificate

Point a DNS record at the VM. A static public IP keeps the address stable, and Azure can also give the VM a <label>.<region>.cloudapp.azure.com name.

Then set the name that Baikal is served as. This mints a new self-signed certificate for the name, updates nginx and the URLs in /root/baikal-credentials.txt, and keeps the name across reboots:

sudo baikal-set-hostname <your-domain>

To use a certificate from a public or internal certificate authority instead (for example one issued by Let's Encrypt for <your-domain>), copy it over the generated pair and reload nginx. Running baikal-set-hostname again replaces it with a self-signed one:

sudo install -m 0644 <your-domain>.crt /etc/ssl/baikal/baikal.crt
sudo install -m 0600 <your-domain>.key /etc/ssl/baikal/baikal.key
sudo nginx -t && sudo systemctl reload nginx
10

Security checks

Confirm that the setup wizard cannot be reached and that the configured version matches the installed one. A mismatch would open Baikal's upgrade page, which asks for no login.

H=$(sudo grep '^baikal.admin.url=' /root/baikal-credentials.txt | cut -d= -f2- | sed -E 's#https://([^/]+)/.*#\1#')
curl -sk -o /dev/null -w '/admin/install/ -> HTTP %{http_code}\n' --resolve "$H:443:127.0.0.1" "https://$H/admin/install/" ls /var/www/baikal/Specific/INSTALL_DISABLED grep -E 'BAIKAL_VERSION"' /var/www/baikal/Core/Distrib.php
sudo grep configured_version /var/www/baikal/config/baikal.yaml [ "$(curl -sk -o /dev/null -w '%{http_code}' --resolve "$H:443:127.0.0.1" "https://$H/admin/install/")" = "404" ]
/admin/install/ -> HTTP 404 /var/www/baikal/Specific/INSTALL_DISABLED define("BAIKAL_VERSION", "0.12.1"); configured_version: '0.12.1'

The upstream fix for GHSA-j44x-cj7p-vx2w escapes user-supplied names wherever the admin console shows them. The edit page title, for example, escapes the calendar or address book name in four places:

grep -c 'htmlspecialchars($this->modelInstance()->label())' /var/www/baikal/Core/Frameworks/Formal/Form.php
4

The VM generated the certificate itself, and it names the VM's own addresses:

H=$(sudo grep '^baikal.admin.url=' /root/baikal-credentials.txt | cut -d= -f2- | sed -E 's#https://([^/]+)/.*#\1#')
echo | openssl s_client -connect 127.0.0.1:443 -servername "$H" 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName
subject=CN = 52.147.201.16, O = sac X509v3 Subject Alternative Name: IP Address:52.147.201.16, IP Address:10.0.0.11, IP Address:127.0.0.1, DNS:localhost

Neither nginx nor PHP-FPM can reach the Azure Instance Metadata Service or the wire server:

systemctl show nginx php8.3-fpm -p Id -p IPAddressDeny
IPAddressDeny=169.254.169.254/32 168.63.129.16/32 Id=nginx.service IPAddressDeny=169.254.169.254/32 168.63.129.16/32 Id=php8.3-fpm.service

nginx hands PHP the configured host name and the client address from the connection, never values a client sends:

grep -E 'fastcgi_param (HTTP_HOST|HTTP_X_FORWARDED_FOR|HTTP_X_REAL_IP|HTTP_CLIENT_IP)' /etc/nginx/sites-available/sac-baikal
fastcgi_param HTTP_HOST $baikal_host; fastcgi_param HTTP_X_FORWARDED_FOR $remote_addr; fastcgi_param HTTP_X_REAL_IP $remote_addr; fastcgi_param HTTP_CLIENT_IP $remote_addr;

nginx also refuses a form post or a DAV change whose Origin is another website (HTTP 403). CalDAV and CardDAV apps send no Origin and are unaffected.

11

Changing passwords

Baikal keeps two passwords, and first boot set both to the same value. Change each one separately:

  • Web admin password: open System Settings, enter a new Admin password and its confirmation, then select Save changes.
  • CalDAV/CardDAV password of the admin account: open Users and resources, select Edit on the admin row, then enter a new password.

After changing them, update or delete baikal.admin.pass in /root/baikal-credentials.txt so that it does not hold an old password. Give each person their own Baikal user rather than sharing admin.

12

Backup and maintenance

All Baikal state is under /var/www/baikal on the data disk: the configuration in config/baikal.yaml and the database in Specific/db/db.sqlite.

The simplest backup is an Azure snapshot of that disk (or Azure Backup for the VM). For a file-level copy that is safe while Baikal is running, use SQLite's online backup:

sudo sqlite3 /var/www/baikal/Specific/db/db.sqlite ".backup '<backup-dir>/baikal-$(date +%F).sqlite'"
sudo cp /var/www/baikal/config/baikal.yaml <backup-dir>/

Operating system security updates install automatically. Run sudo apt-get update && sudo apt-get upgrade to apply everything else. nginx and PHP-FPM restart cleanly, and Baikal keeps all of its data.

Component Path

Baikal /var/www/baikal (data disk)

Configuration /var/www/baikal/config/baikal.yaml

Database /var/www/baikal/Specific/db/db.sqlite

nginx site /etc/nginx/sites-available/sac-baikal

Certificate and key /etc/ssl/baikal/

First boot script /usr/local/sbin/baikal-firstboot.sh

Host name helper /usr/local/sbin/baikal-set-hostname

Credentials /root/baikal-credentials.txt (mode 0600)

13

Security recommendations

  • Restrict network access. Allow port 22 from your admin network only, and ports 80 and 443 only from the networks your devices sync from.
  • Use your own name and certificate. Set your own DNS name and a certificate that your devices trust (Step 9).
  • Rotate shared passwords. Change the passwords if they may have been shared, and give each person their own Baikal user (Step 11).
  • Keep the VM patched and backed up. Keep the operating system patched and snapshot the data disk regularly.

Support and licensing

Baikal is open source under the GNU General Public License version 3, and there is no per-user or per-CPU licence fee. The image price covers SecureAnyCloud's hardening, first-boot setup and support.

  • Email: sales@secureanycloud.com
  • Website: https://secureanycloud.com
  • Support: 24/7, with a 24-hour response SLA

Deploy on Azure

Launch Baikal 0.12.1 on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.

View this image

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on Azure