Applications Azure

Cylc on Ubuntu 24.04 (Azure) User Guide

| Product: Cylc on Ubuntu 24.04

Overview

Cylc is a workflow engine and meta scheduler designed for cycling workflows. A conventional pipeline tool runs a graph once per trigger; Cylc runs the same graph repeatedly over a sequence of cycle points, and lets a task in one cycle depend on a task in the cycle before it. That is why Cylc is the engine behind operational numerical weather prediction and climate suites at national meteorological services and academic computing centres — a forecast suite is not a pipeline that finishes, it is a graph that keeps cycling.

This image ships both halves of Cylc: cylc-flow, the scheduler and command line that actually runs your task graph, and cylc-uiserver, the web interface that shows the live state of every task in every cycle. A working example workflow is included so you can watch a real cycling graph run to completion within a minute of first boot.

The UI Server is built on Jupyter Server, and it can trigger workflow tasks — which means it can cause code to run. This image is built around that fact. The UI Server runs as a dedicated unprivileged account with no sudo rights, bound to the loopback interface only, and is published solely through an nginx TLS reverse proxy on :443 that serves an explicit allowlist of the product's own paths. Token credentials that travel in the address bar are switched off; instead the first boot of every VM generates a long password unique to that VM, stores only its argon2 hash, and records the password in a root-only file. If that first boot has not run, the UI Server refuses to start rather than come up unauthenticated.

What is included:

  • cylc-flow 8.6.5 and cylc-uiserver 1.9.3 (both GPL-3.0) in a Python 3.12 virtual environment at /opt/cylc/venv, with cylc on the system PATH
  • The Cylc UI Server bound to loopback 127.0.0.1:8890, published only through nginx TLS on :443
  • A worked example cycling workflow at /var/lib/cylc/cylc-src/cloudimg-demo, plus a one-command runner that plays it to completion and checks every task succeeded
  • A dedicated 64 GiB Azure managed data disk mounted at /var/lib/cylc, holding workflow sources, run directories, job logs and task-state databases
  • A password unique to this VM, generated at first boot and written to /root/cylc-credentials.txt
  • cylc-uiserver.service and nginx.service as systemd units, enabled and active, with the UI Server confined by NoNewPrivileges, ProtectSystem=strict and PrivateTmp
  • The verbatim GPL-3.0 licence texts and the corresponding upstream source at /usr/share/doc/cylc
  • 24/7 cloudimg support

Connecting to your VM

Connect over SSH as azureuser (the administrator account you configured when creating the VM), using the SSH key pair you selected at launch:

ssh -i /path/to/your-key azureuser@<public-ip>

Prerequisites

An Azure subscription, an SSH key pair, and a virtual network subnet in your target region. Standard_B2s (2 vCPU / 4 GiB RAM) runs the engine, the UI and the bundled example comfortably; choose a larger size for real suites with many concurrent tasks. Network security group inbound: allow 22/tcp from your management network and 443/tcp from the people who need the workflow interface. Nothing else needs to be open.

Because anyone who signs in to the UI can trigger workflow tasks — and a task is a shell script — treat 443/tcp as an administrative port: restrict it to your own networks, or reach it over a VPN or an SSH tunnel rather than publishing it to the internet.

A running Cylc scheduler also opens a CurveZMQ socket in the range 43001-43100 so that Cylc clients and the UI Server can talk to it. On this single-VM appliance those sockets are only ever used from the VM itself, and a client needs the workflow's own key pair (kept root-private inside the run directory) to talk to them. Do not open 43001-43100 in your network security group.

Step 1 — Launch from the Azure Marketplace

Find Cylc Workflow Engine on Ubuntu 24.04 by cloudimg in the Azure Marketplace, choose Get It Now, then Create. Pick your subscription, resource group and region, a VM size (Standard_B2s or larger), your SSH public key for the azureuser account, and a network security group that allows 22/tcp and 443/tcp, then review and create.

Step 2 — Launch from the Azure CLI

az vm create \
  --resource-group <your-rg> \
  --name cylc \
  --image <marketplace-image-urn> \
  --size Standard_B2s \
  --admin-username azureuser \
  --ssh-key-values ~/.ssh/id_rsa.pub \
  --public-ip-sku Standard

az vm open-port --resource-group <your-rg> --name cylc --port 443 --priority 900

The dedicated data disk is captured inside the image, so it is re-provisioned automatically on every VM you launch — there is no separate disk to attach.

Step 3 — Confirm the services are running

The UI Server and nginx come up automatically once first boot has completed.

systemctl is-active cylc-uiserver.service nginx.service | tr '\n' ' ' | grep -q 'active active' \
  || { echo "FATAL: cylc-uiserver and nginx are not both active"; exit 1; }
ss -tlnH | awk '{print $4}' | grep -qx '127.0.0.1:8890' \
  || { echo "FATAL: the UI Server is not bound to loopback"; exit 1; }
ss -tlnH | awk '{print $4}' | grep -q ':443$' \
  || { echo "FATAL: nginx is not listening on 443"; exit 1; }
cylc version
echo "cylc-uiserver + nginx active; UI Server on loopback 127.0.0.1:8890; TLS on :443"

The UI Server on 8890 is bound to 127.0.0.1 and is never reachable from the network. Only nginx on :443 is exposed, and it is the sole route to the interface. The UI Server runs as the unprivileged cylc account, never as root:

The cylc-uiserver and nginx services active, the UI Server listening on loopback 127.0.0.1:8890 only, nginx TLS on 443, the service running as the unprivileged cylc account, and the installed cylc-flow and cylc-uiserver versions

Step 4 — Confirm first boot minted this VM's own credentials

Every VM mints its own TLS certificate, its own UI Server password and its own cookie-signing secret on first boot. Nothing is shared between deployments:

test -f /var/lib/cloudimg/cylc-firstboot.done \
  || { echo "FATAL: first boot has not completed on this VM"; exit 1; }
openssl x509 -noout -subject -dates -ext subjectAltName -in /etc/nginx/tls/cert.pem \
  || { echo "FATAL: no per-VM TLS certificate found"; exit 1; }
head -c 7 /etc/cylc/uiserver/hashed_password | grep -q argon2 \
  || { echo "FATAL: no per-VM argon2 password hash found"; exit 1; }
echo "first boot complete: per-VM certificate and argon2 password hash in place"

The certificate and key live in /etc/nginx/tls, readable by root only, and only the argon2 hash of the password is stored. The image itself ships with no certificate, no key and a blank hash file — they exist only after your VM has booted.

The per-VM TLS certificate minted at first boot with the VM address in its subject alternative name, the root-only credentials file and hashed password file permissions, and the stored argon2 hash prefix

Step 5 — Retrieve the password generated for your VM

There is no shared default password. Read the root-only credentials file over SSH:

sudo cat /root/cylc-credentials.txt

The file is mode 0600 root:root and contains this VM's URL and its password. Note the password somewhere safe. The check below confirms the file is present, root-only and populated without printing the secret:

test -f /root/cylc-credentials.txt \
  || { echo "FATAL: the credentials file is missing"; exit 1; }
stat -c 'credentials file: mode %a owner %U:%G' /root/cylc-credentials.txt
grep -q '^cylc.url=https://' /root/cylc-credentials.txt \
  || { echo "FATAL: no resolved VM URL recorded"; exit 1; }
awk -F= '/^cylc\.password=/ { if (length($2) >= 28) { print "per-VM password recorded: " length($2) " characters"; found=1 } }
         END { if (!found) { print "FATAL: no per-VM password recorded"; exit 1 } }' \
  /root/cylc-credentials.txt

Step 6 — Sign in to the Cylc UI

Browse to https://<public-ip>/. You are redirected to /cylc/, and because you are not signed in yet, on to the sign-in page. The certificate is self-signed and minted for your VM, so your browser warns on the first visit — accept it, or install your own certificate (Step 11). There is no username: enter the password from Step 5.

The Cylc UI Server sign-in page served over HTTPS, asking for a password only

After signing in you land on the Cylc dashboard. It shows how many workflows are running, paused, stopping and stopped, with the workflow list in the left-hand panel and the running Cylc version at the bottom.

The Cylc dashboard showing one running workflow, the workflow list in the side panel, and Cylc 8.6.5

The Cylc Hub and Jupyter Lab entries on the dashboard are greyed out. They belong to Cylc's multi-user Hub deployment, which this single-VM appliance deliberately does not install.

Step 7 — Run the bundled example cycling workflow

The image ships a small cycling workflow at /var/lib/cylc/cylc-src/cloudimg-demo. It runs a miniature forecast suite over three six-hourly cycle points:

get_observations => run_model => publish_products => housekeep
publish_products[-PT6H] => run_model

That second line is the part a plain DAG scheduler cannot express: a cycle's model run waits for the previous cycle's products. Run the whole thing with the bundled runner, which validates it, installs it, plays it to completion and then checks that every task in every cycle actually succeeded:

sudo /usr/local/lib/cloudimg/cylc-run-demo.sh 2>&1 | tail -12

It finishes in well under a minute and prints CYLC_DEMO_SUCCESS along with the three forecast products the run produced:

The bundled cycling workflow validated, installed and played to completion, reporting 12 task jobs succeeded across 3 cycle points and printing the three published forecast products

While it runs, open the workflow in the UI from the left-hand panel. The Tree view shows every cycle point, every task in it, and the state of each one — waiting, running or succeeded — updating live over a GraphQL subscription:

The Cylc UI tree view showing all three cycle points of the running workflow, each with its get_observations, run_model, publish_products and housekeep tasks and their live states

A few seconds later the same view has moved on: the first cycle has published its products, the second cycle's model run is in flight, and the third is still waiting on it. That progression through the cycling graph is the whole point of Cylc.

The same workflow moments later, with the first cycle point completed, the second cycle point's run_model task running, and the third cycle point still waiting

Inspect what the run produced, and the task states Cylc recorded in the workflow database:

RUN=/var/lib/cylc/cylc-run/cloudimg-demo/runN
cat "$RUN"/share/products/forecast-*.txt \
  || { echo "FATAL: the workflow published no products"; exit 1; }
sqlite3 "$RUN/log/db" 'SELECT cycle, name, status FROM task_states ORDER BY cycle, name;' \
  || { echo "FATAL: could not read the workflow task-state database"; exit 1; }

Step 8 — Write your own workflow

Cylc reads workflow sources from ~/cylc-src and installs each run into ~/cylc-run. Everything must run as the cylc account: the UI Server only ever sees its own owner's run directory, so a workflow you install as azureuser will run but will not appear in the interface.

Copy the bundled example as a starting point and validate your edit:

sudo -u cylc -H sh -c 'cd ~; test -d cylc-src/my-workflow || cp -r cylc-src/cloudimg-demo cylc-src/my-workflow; cylc validate ./cylc-src/my-workflow' \
  || { echo "FATAL: the copied workflow did not validate"; exit 1; }
echo "my-workflow validated"

Give Cylc a path, not a name. A bare relative argument such as cylc-src/my-workflow is read as a workflow ID and looked up under ~/cylc-run, so it fails with Workflow ID not found. Write ./cylc-src/my-workflow (or an absolute path) when you mean a source directory on disk.

Edit /var/lib/cylc/cylc-src/my-workflow/flow.cylc (as root, or as the cylc user), then install and play it:

sudo -u cylc -H sh -c 'cd ~; cylc install ./cylc-src/my-workflow'
sudo -u cylc -H sh -c 'cd ~; cylc play my-workflow'

Useful day-to-day commands, all run the same way:

sudo -u cylc -H sh -c 'cd ~; cylc scan --states=all --format=name' \
  || { echo "FATAL: cylc scan failed"; exit 1; }
sudo -u cylc -H sh -c 'cd ~; cylc config --item "[scheduler]UTC mode" ./cylc-src/cloudimg-demo' \
  || { echo "FATAL: cylc config failed"; exit 1; }

To stop a running workflow, or to remove a run directory entirely:

sudo -u cylc -H sh -c 'cd ~; cylc stop my-workflow'
sudo -u cylc -H sh -c 'cd ~; cylc clean --yes my-workflow'

The cd ~ matters. Cylc's configuration parser returns to the directory it was invoked from, and the cylc account cannot read another user's home directory — so running these from /home/azureuser fails with a permission error. Running from the cylc account's own home avoids it.

Step 9 — Prove the security contract end to end

The image ships a probe that exercises the whole authentication chain against the live HTTPS endpoint on this VM. It proves that unauthenticated REST and GraphQL calls are refused, that a wrong password is refused and yields no usable session, that this VM's own password signs in as the UI Server owner with real Cylc permissions, and that the Cylc GraphQL schema answers a genuine query:

/opt/cylc/venv/bin/python /usr/local/lib/cloudimg/cylc-uiserver-probe.py \
  || { echo "FATAL: the security round-trip probe did not pass"; exit 1; }

It reads the password from the root-only credentials file itself and never prints it. Run it any time you want to confirm the VM is healthy — after a certificate change, after a password change, or as a smoke test following an upgrade.

The reverse proxy publishes only the paths this product actually serves. Anything else returns 404 without ever reaching the UI Server:

for p in /api/contents /nbextensions /tree /metrics; do
  code=$(curl -sk -o /dev/null -w '%{http_code}' -m 10 "https://127.0.0.1${p}")
  [ "$code" = "404" ] || { echo "FATAL: unlisted path $p returned HTTP $code, expected 404"; exit 1; }
done
echo "reverse-proxy allowlist confirmed: unlisted paths return 404"

The end-to-end security probe passing all six checks, an unauthenticated GraphQL call refused with HTTP 403, and an unlisted proxy path returning HTTP 404

Step 10 — Confirm the confined runtime and the dedicated disk

The UI Server runs as the unprivileged cylc account with no sudo rights, and systemd confines it: privilege escalation is blocked, the filesystem is read-only apart from the workflow volume, and /tmp is private to the service.

MAINPID="$(systemctl show -p MainPID --value cylc-uiserver.service)"
ps -o user= -p "$MAINPID" | tr -d ' ' | grep -qx cylc \
  || { echo "FATAL: the UI Server is not running as the cylc account"; exit 1; }
grep -rq '^cylc[[:space:]]' /etc/sudoers /etc/sudoers.d/ 2>/dev/null \
  && { echo "FATAL: the cylc account has sudo rights"; exit 1; }
systemctl show cylc-uiserver.service -p NoNewPrivileges -p ProtectSystem -p PrivateTmp -p ReadWritePaths
echo "UI Server runs unprivileged and confined"

Workflow sources, run directories, job logs and task-state databases live on their own Azure managed data disk, mounted by filesystem UUID so it reproduces identically on every VM launched from this image:

findmnt -no SOURCE,SIZE,TARGET /var/lib/cylc \
  || { echo "FATAL: the workflow disk is not mounted"; exit 1; }
[ "$(findmnt -no SOURCE /)" != "$(findmnt -no SOURCE /var/lib/cylc)" ] \
  || { echo "FATAL: the workflow volume shares a device with the operating system disk"; exit 1; }
grep -q ' /var/lib/cylc ' /etc/fstab \
  || { echo "FATAL: the workflow disk is not recorded in /etc/fstab"; exit 1; }
echo "workflow disk mounted separately from the OS disk and persisted in /etc/fstab"

Run directories are exactly the thing that grows on a workflow host. To grow the volume, expand the managed data disk in the Azure portal (or with az disk update --size-gb), then extend the filesystem with sudo resize2fs $(findmnt -no SOURCE /var/lib/cylc).

Step 11 — Change the password and replace the certificate

To set your own password, generate a hash with Jupyter Server's own hashing helper (it prompts twice and never echoes), write it to the hash file and restart:

sudo /opt/cylc/venv/bin/python -c "from jupyter_server.auth import passwd; print(passwd())" \
  | sudo tee /etc/cylc/uiserver/hashed_password
sudo chown root:cylc /etc/cylc/uiserver/hashed_password
sudo chmod 640 /etc/cylc/uiserver/hashed_password
sudo systemctl restart cylc-uiserver

Only the hash is stored. If the hash file is ever emptied, cylc-uiserver.service refuses to start rather than serve an unauthenticated interface — that is the intended behaviour, not a fault.

The image serves HTTPS immediately using the self-signed certificate minted for your VM. For any real deployment, install a certificate for your VM's DNS name from your internal CA or a public issuer, and point nginx at it:

sudo cp your-fullchain.pem /etc/nginx/tls/cert.pem
sudo cp your-private-key.pem /etc/nginx/tls/key.pem
sudo chmod 600 /etc/nginx/tls/key.pem
sudo nginx -t && sudo systemctl reload nginx

Step 12 — Versions, licences, backup and maintenance

Confirm what is installed, and that both components are the GPL-3.0 upstream releases:

cylc version || { echo "FATAL: cylc is not on the PATH"; exit 1; }
/opt/cylc/venv/bin/python -c "import importlib.metadata as m; [print(p, m.version(p)) for p in ('cylc-flow','cylc-uiserver','jupyter-server')]" \
  || { echo "FATAL: could not read the installed package versions"; exit 1; }
grep -q 'Version 3, 29 June 2007' /usr/share/doc/cylc/COPYING.cylc-flow \
  || { echo "FATAL: the cylc-flow licence artifact is missing or wrong"; exit 1; }
ls /usr/share/doc/cylc/
echo "cylc-flow and cylc-uiserver are GPL-3.0; licences and corresponding source are on this VM"

Cylc is shipped unmodified. The verbatim licence texts and the pinned upstream source distributions the installed versions were built from are in /usr/share/doc/cylc.

Everything you create lives under /var/lib/cylc on the dedicated disk, so a snapshot of that managed disk is a complete backup of your workflows and their history. Take one on a schedule from the Azure portal or the CLI, or copy the source tree elsewhere:

sudo tar -czf /var/backups/cylc-src-$(date +%F).tar.gz -C /var/lib/cylc cylc-src

Workflow schedulers are ordinary processes started by cylc play, not systemd units, so they do not restart automatically after a VM reboot. Restart the ones you want with cylc play <workflow> after the VM comes back, or drive it from your own systemd unit or cron entry.

The image keeps unattended security upgrades enabled, so the operating system receives patches automatically. UI Server configuration lives in /etc/cylc/uiserver/jupyter_config.py and the proxy configuration in /etc/nginx/sites-available/zz-cloudimg-cylc; restart with sudo systemctl restart cylc-uiserver nginx after any change. Service logs are in the journal, and each workflow keeps its own scheduler and job logs in its run directory:

sudo journalctl -u cylc-uiserver.service -n 50 --no-pager
sudo journalctl -u nginx.service -n 50 --no-pager
sudo less /var/lib/cylc/cylc-run/cloudimg-demo/runN/log/scheduler/log

Support

Every cloudimg deployment includes 24/7 support. If you have any questions about this image, contact us through cloudimg.co.uk.