Applications AWS

Baikal 0.11 on AWS User Guide

| Product: Baikal 0.11 on Ubuntu 24.04 on AWS

Overview

This guide covers launching and using the Baikal 0.11 on Ubuntu 24.04 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg office suites and workspaces listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.

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), and gives you a small web admin console for users, calendars and address books. Everything is stored in SQLite, so there is no database server to run.

What the cloudimg image adds:

  • HTTPS from the 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 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, and 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/.
  • An upstream security fix backported. Baikal 0.12.0 and earlier let any signed in CalDAV user take over the admin console by renaming a calendar to HTML (advisory GHSA-j44x-cj7p-vx2w, fixed in Baikal 0.12.1). The same escaping is applied to this 0.11.1 build, and the build proves it by renaming a calendar to an HTML payload and checking the admin pages show it as text.
  • Hardened defaults. nginx and PHP-FPM are blocked from the instance metadata service, the AMI requires IMDSv2, PHP is handed the configured host name and the real client address from the connection (never values a client sends), and 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.
  • Fully patched Ubuntu 24.04 LTS at build time, with unattended security upgrades switched on.

What is on the image: Baikal 0.11.1 (the official release zip, checksum verified, sabre/dav 4.7.0), nginx, PHP 8.3 FPM with OPcache, SQLite, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.

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, and TCP 443 (Baikal) and TCP 80 (redirects to HTTPS) from the networks your calendar and contact clients use.

Step 1: Launch the instance

In the AWS Marketplace listing choose Continue to Subscribe, then Continue to Configuration, pick Baikal 0.11 on Ubuntu 24.04 as the software version and your Region, then Launch through EC2. Choose m5.large (Baikal itself is light; a smaller size is fine for a handful of users), your key pair and a security group as above. Keep the second EBS volume: it holds Baikal and its data.

With the AWS CLI (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.

Step 2: Connect to your instance

Version SSH user
Baikal 0.11 on Ubuntu 24.04 ubuntu
ssh -i <your-key.pem> ubuntu@<public-ip>

Step 3: Check the services

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

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

All three units are 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",...))

Baikal and its SQLite database live on the dedicated data volume:

df -h /var/www/baikal | tail -1
/dev/nvme1n1     20G  9.9M   19G   1% /var/www/baikal

Step 4: Get your URLs and password

sudo cat /root/baikal-credentials.txt

The file is readable by root only:

# Baikal 0.11 - 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://www.cloudimg.co.uk/guides/baikal-0-11-on-ubuntu-24-04-aws/

The URLs use the instance's public DNS name from EC2 instance metadata. The certificate also names the public and private IP addresses, so https://<public-ip>/admin/ works too. To serve Baikal under your own DNS name, see Step 9.

Step 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 (or install your own, Step 9). Sign in as admin with the password from Step 4:

The Baikal Web Admin authentication page with the Login and Password fields

The dashboard shows the Baikal version, which services are on (web admin, CalDAV, CardDAV) and counts of users, calendars, events, address books and contacts:

The Baikal dashboard showing version 0.11.1, the Web admin, CalDAV and CardDAV services on, and statistics for one user, one calendar with three events and one address book with two contacts

Step 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:

The Baikal users list with the admin account and its Calendars, Address Books, Edit and Delete buttons, and the Add user button

Calendars and Address Books on each user row manage that user's collections:

The calendars page for user admin, listing the Default calendar with three events and its Edit and Delete buttons

System Settings controls CalDAV and CardDAV, the time zone, the invite sender address, the authentication type and the web admin password:

The Baikal system settings page with the time zone, Enable CardDAV and CalDAV, the email invite sender, WebDAV authentication type Digest and the admin password fields

Step 7: A CalDAV and CardDAV round trip from the command line

This block signs in with the instance's password over HTTPS, lists the admin calendars, creates an event and a contact, reads them back, deletes them, and shows 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; }
  printf "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//cloudimg//guide//EN\r\nBEGIN:VEVENT\r\nUID:guide-event\r\nDTSTAMP:20261019T120000Z\r\nDTSTART:20261020T090000Z\r\nDTEND:20261020T100000Z\r\nSUMMARY:Guide test event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" > /root/guide-event.ics
  printf "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:guide-card\r\nFN:Guide Test Contact\r\nEND:VCARD\r\n" > /root/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 @/root/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 @/root/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"
  rm -f /root/guide-event.ics /root/guide-card.vcf
'
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

Step 8: Connect your calendar and contacts apps

Use the baikal.dav.url from Step 4 as the server address, 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 your devices trust (Step 9).

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 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

Step 10: Security checks

The setup wizard cannot be reached, and the backported fix is recorded on the image:

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
cat /var/www/baikal/CLOUDIMG-SECURITY-BACKPORTS.txt
[ "$(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.11.1");
cloudimg: Baikal 0.11.1 with the GHSA-j44x-cj7p-vx2w admin XSS fix (Baikal 0.12.1, sabre-io/Baikal#1430) backported.

The 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 subjectAltName
subject=CN = ec2-<public-ip-dashed>.compute-1.amazonaws.com, O = cloudimg
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:localhost

Neither nginx nor PHP-FPM can reach the instance metadata service:

systemctl show nginx php8.3-fpm -p Id -p IPAddressDeny
IPAddressDeny=168.63.129.16/32 fd00:ec2::254/128 169.254.169.254/32
Id=nginx.service

IPAddressDeny=168.63.129.16/32 fd00:ec2::254/128 169.254.169.254/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/cloudimg-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 web site (HTTP 403). CalDAV and CardDAV apps send no Origin and are unaffected.

Step 11: Changing passwords

Baikal keeps two passwords, and first boot set both to the same value:

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

After changing 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.

Step 12: 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/cloudimg-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)

Step 13: Security recommendations

  • Allow port 22 from your admin network only, and ports 80 and 443 only from the networks your devices sync from.
  • Use your own DNS name and a certificate your devices trust (Step 9).
  • Change the passwords if they may have been shared, and give each person their own Baikal user (Step 11).
  • 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 licence fee. The AMI price covers cloudimg's hardening, first boot setup and support.

  • Email: support@cloudimg.co.uk
  • Website: https://www.cloudimg.co.uk
  • Support: 24/7, with a 24 hour response SLA