Baikal 0.12.1
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 the open standards they already speak: 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 the Baikal 0.12.1 on Ubuntu 24.04 AMI from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud office suites and workspaces listing on AWS Marketplace: you pick it as the software version when you subscribe and launch.
What you get:
- HTTPS from first boot. nginx serves Baikal on port 443 with a certificate generated on your instance's first boot. Port 80 redirects to HTTPS, and requests for a host name the instance does not serve are dropped.
- A password unique to your instance. The web
adminand 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. 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 it as text. It also checks that the same test does catch the payload when escaping is switched off.
- Hardened defaults. nginx and PHP-FPM are blocked from the instance metadata service, and the AMI requires IMDSv2. 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 volume.
/var/www/baikal(code, configuration and the SQLite database) is its own 20 GiB EBS volume that you can grow or snapshot on its own. - A patched base. Ubuntu 24.04 LTS is fully patched at build time, with unattended security upgrades enabled.
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 AWS Systems Manager agent. Build tools used only during the image build (such as unzip) are removed, and no compiler is installed.
Default user: ubuntu | Architecture: x86_64 | Recommended instance type: m5.large
Prerequisites
- An AWS account subscribed to the listing, an EC2 key pair, and a VPC subnet with a route to the internet (or a private route you use for management).
- A security group that allows TCP 22 (SSH) from your admin network. It should also allow TCP 443 (Baikal) and TCP 80 (redirects to HTTPS) from the networks your calendar and contact clients use.
Launch the instance
In the AWS Marketplace listing, choose Continue to Subscribe, then Continue to Configuration. Pick Baikal 0.12.1 on Ubuntu 24.04 as the software version, select your Region, and choose Launch through EC2.
Choose m5.large, your key pair and the security group from the prerequisites. Baikal itself is light, so a smaller size is fine for a handful of users. Keep the second EBS volume, because it holds Baikal and its data.
To launch with the AWS CLI instead, replace the values in angle brackets:
aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
--instance-type m5.large --key-name <your-key-pair> \
--security-group-ids <your-security-group> --subnet-id <your-subnet> \
--metadata-options HttpTokens=required,HttpEndpoint=enabled \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=baikal}]'First boot takes well under a minute after the instance is running.
Connect to your instance
Connect over SSH as the ubuntu user, which is the default user for Baikal 0.12.1 on Ubuntu 24.04:
ssh -i <your-key.pem> ubuntu@<public-ip>Check the services
First boot generates the certificate and the password, and then nginx and PHP-FPM start. Check the units:
systemctl is-active nginx php8.3-fpm baikal-firstboot
sudo test -f /var/lib/sac/data/baikal-firstboot.done && echo FIRSTBOOT_DONEAll three units are active and the first-boot marker is present:
active active active FIRSTBOOT_DONEOnly 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' | sort0.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",...))Baikal and its SQLite database live on the dedicated data volume:
df -h /var/www/baikal | tail -1/dev/nvme1n1 20G 12M 19G 1% /var/www/baikalGet your URLs and password
sudo cat /root/baikal-credentials.txtThe file is readable by root only:
# 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://ec2-<public-ip-dashed>.compute-1.amazonaws.com/admin/ baikal.dav.url=https://ec2-<public-ip-dashed>.compute-1.amazonaws.com/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-0-12-1/The URLs use the instance's public DNS name from EC2 instance metadata. The certificate also names the public and private IP addresses, so CalDAV and CardDAV clients can connect to https://<public-ip>/dav.php/ as well.
Open the web admin at the DNS name in baikal.admin.url, though. Its pages link to that configured name, so a sign-in started at an IP address is sent across sites and refused (HTTP 403).
To serve Baikal under your own DNS name, see Step 9.
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. You can also 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:
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.
Select + 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:
Calendars and Address Books on each user row manage that user's collections:
System Settings controls CalDAV and CardDAV, the time zone, the invite sender address, the authentication type and the web admin password:
A CalDAV and CardDAV round trip from the command line
The commands below sign in with the instance's password over HTTPS and list the admin calendars. They create an event and a contact, read them back and delete them. They also show that a wrong password is refused. The password is handed 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 204Connect 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 DNS name
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://ec2-<public-ip-dashed>.compute-1.amazonaws.com/dav.php/ .well-known/carddav -> 301 https://ec2-<public-ip-dashed>.compute-1.amazonaws.com/dav.php/ http:// -> 301 https://ec2-<public-ip-dashed>.compute-1.amazonaws.com/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 instance a DNS name and a certificate that your devices trust (Step 9).
Your own DNS name and certificate
Point a DNS record at the instance. An Elastic IP keeps the address stable. Then set the name that Baikal is served under. The command creates 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, copy it over the generated pair and reload nginx. For example, use one issued by Let's Encrypt for <your-domain>. 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 nginxSecurity 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.php4The certificate was generated on this instance and names its 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 subjectAltNamesubject=CN = ec2-<public-ip-dashed>.compute-1.amazonaws.com, O = sac X509v3 Subject Alternative Name: DNS:ec2-<public-ip-dashed>.compute-1.amazonaws.com, IP Address:<public-ip>, IP Address:<private-ip>, IP Address:127.0.0.1, DNS:localhostNeither nginx nor PHP-FPM can reach the instance metadata service:
systemctl show nginx php8.3-fpm -p Id -p IPAddressDenyIPAddressDeny=168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128 Id=nginx.service IPAddressDeny=168.63.129.16/32 169.254.169.254/32 fd00:ec2::254/128 Id=php8.3-fpm.servicenginx 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-baikalfastcgi_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.
Changing passwords
Baikal keeps two passwords, and first boot set both to the same value:
- Web admin password: open System Settings, enter a new Admin password and its confirmation, then select Save changes.
- CalDAV/CardDAV password of the
adminaccount: open Users and resources, select Edit on theadminrow, and enter a new password.
After you change them, update or delete baikal.admin.pass in /root/baikal-credentials.txt so it does not hold an old password. Give each person their own Baikal user rather than sharing admin.
Backup and maintenance
All Baikal state is under /var/www/baikal on the data volume: the configuration in config/baikal.yaml and the database in Specific/db/db.sqlite.
The simplest backup is an EBS snapshot of that volume. 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 volume)
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)
Security recommendations
- Restrict the network. Allow port 22 from your admin network only, and ports 80 and 443 only from the networks your devices sync from.
- Use a trusted certificate. Use 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).
- Patch and back up. Keep the operating system patched, and snapshot the data volume regularly.
Support and licensing
Baikal is open source under the GNU General Public License version 3. There is no per-user or per-CPU license fee. The AMI 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 AWS
Launch Baikal 0.12.1 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.
View on Marketplace
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com