Wexflow on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and configuration of Wexflow on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. Wexflow is an open source workflow engine and automation platform published under the MIT licence.
Wexflow exists for the work that keeps a business running but that nobody wants to do by hand: move the files that arrived overnight, unpack and convert them, hash and encrypt them, push them somewhere over SFTP, call an API, run a query, wait for a person to approve the result, then tell somebody it is done. You assemble that out of ready made tasks in a browser, rather than writing and maintaining a pile of scripts.
A workflow is a list of tasks with settings. Wexflow ships more than one hundred of them covering file and folder operations, archives, encryption and hashing, HTTP calls, SQL queries, SSH and FTP transfers, image and document conversion, electronic mail and scripting. Workflows run on demand, on a cron or periodic schedule, or automatically when a file lands in a watched folder. An approval and records module lets a person sign off a step before the rest continues. Everything the interface can do is also available through a documented REST API.
The image ships the official upstream Wexflow 10.0 release, fetched from the project's own GitHub release and pinned by SHA-256. The exact artifact, its digest, its licence and the runtime it was built against are recorded on the image at /opt/wexflow/VERSION. Wexflow runs under systemd as wexflow.service under an unprivileged wexflow account, behind nginx on port 80.
A workflow engine that can prove it works. A sign in page that returns a web page tells you nothing about whether the engine can execute a task. So the image ships a small cloudimg authored workflow, cloudimg_Health_Check, that creates a file and copies it. Running it is a one click proof that the engine, the scheduler, the task library and the database are all working together, and it is the same proof this instance ran on itself during its first boot.
What is included:
- Wexflow 10.0 (official upstream release, SHA-256 pinned, redistributed unmodified)
- The complete upstream library of example workflows, one per task type, ready to open and copy
- The
cloudimg_Health_Checkworkflow, a one click end to end proof that the engine executes tasks - An embedded SQLite database, so there is no external database to provision or point at
- nginx on port 80 as the single entry point, with an unauthenticated
/healthzfor load balancer probes - A unique administrator password, token signing key and certificate passphrase generated on first boot
- The ASP.NET Core 10 runtime from the Ubuntu archive, so it keeps receiving security updates
- Ubuntu 24.04 LTS, fully patched at build time, with unattended security updates left enabled
- 24/7 cloudimg support
Key facts:
| Item | Value |
|---|---|
| Platform | Ubuntu 24.04 LTS (Azure Gen2) |
| Default SSH user | azureuser |
| Admin panel | http://<vm-ip>/admin/ |
| REST API base | http://<vm-ip>/api/v1/ |
| Services | wexflow.service, nginx.service |
| Runs as | wexflow (unprivileged, no login shell) |
| Application root | /opt/wexflow |
| Database | /opt/wexflow/Wexflow/Database/Wexflow.sqlite (embedded SQLite) |
| Credentials file | /root/wexflow-credentials.txt (0600 root:root) |
| HTTP port | 80 |
| Engine port | 8000 (behind the proxy; leave it closed in your network security group) |
Prerequisites
Before deploying, ensure you have:
- An active Azure subscription
- An SSH key pair, or the Azure Portal ability to generate one
- Permission to create virtual machines, virtual networks and network security groups
- A network security group that allows inbound TCP 22 from your management network, and inbound TCP 80 from wherever the admin panel should be reachable
Recommended VM size: Standard_B2s (2 vCPU, 4 GiB). With the full example catalogue loaded the engine uses about 140 MiB of memory and the whole appliance about 600 MiB, so 4 GiB leaves a very large margin. Size up only for the workflows you intend to run in parallel and the files they handle.
Step 1: Deploy from the Azure Portal
- In the Azure Portal, choose Create a resource and search the Marketplace for Wexflow on Ubuntu 24.04 LTS by cloudimg.
- Select the offer and choose Create.
- On the Basics tab pick your subscription, resource group and region, set the VM name, and choose size
Standard_B2s. - Leave the authentication type as SSH public key and the username as
azureuser. - Under Inbound port rules allow SSH (22) and HTTP (80).
- Review and create. The VM is ready in about a minute. First boot then generates this instance's own administrator password and token signing key, and nothing listens on port 80 until it has.
Step 2: Deploy from the Azure CLI
az group create --name wexflow-rg --location eastus
az vm create \
--resource-group wexflow-rg \
--name wexflow-vm \
--image cloudimg:wexflow:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
# the public address of the new VM
az vm show --resource-group wexflow-rg --name wexflow-vm \
--show-details --query publicIps --output tsv
Then open the admin panel port:
az network nsg rule create --resource-group wexflow-rg \
--nsg-name wexflow-vmNSG --name allow-http \
--priority 1010 --destination-port-ranges 80 \
--protocol Tcp --access Allow --direction Inbound
Step 3: Connect and read this instance's sign in details
ssh azureuser@<vm-ip>
Every instance generates its own administrator password on first boot and writes it to a root only file. Read it:
sudo cat /root/wexflow-credentials.txt
Expected output:
# Wexflow on Ubuntu 24.04 LTS by cloudimg — per-VM credentials
# Generated at first boot on 2026-08-07T21:31:08Z. Keep this file private.
#
# Deployment guide: https://www.cloudimg.co.uk/guides/wexflow-on-ubuntu-24-04-azure/
WEXFLOW_URL=http://20.124.233.51/admin/
WEXFLOW_ADMIN_USERNAME=admin
WEXFLOW_ADMIN_PASSWORD=<the unique password generated for your instance>
# Sign in at http://20.124.233.51/admin/ and change this password from Users in the admin panel.
# The JWT signing key and the HTTPS pfx passphrase were also generated uniquely for this
# instance and live in /opt/wexflow/Wexflow.Server/appsettings.json (root:wexflow 0640).
The address shown is your own instance's public address, and the password is unique to it. No cloudimg image ever ships a shared or published password.
Confirm both services are up, and check exactly which upstream artifact this image shipped:
systemctl is-active wexflow.service nginx.service
cat /opt/wexflow/VERSION
curl -s http://127.0.0.1/api/v1/hello
Expected output:
active
active
product=Wexflow
version=10.0
upstream=https://github.com/aelassas/wexflow
release=https://github.com/aelassas/wexflow/releases/download/v10.0/wexflow-10.0-linux-netcore.zip
release_sha256=4c7c051b12d2bea108ad4f1c819ad1849fe2daed14bdbdd1ef7e1974f406bf5a
licence=MIT (verbatim text at /opt/wexflow/LICENSE.txt)
runtime=aspnetcore-runtime-10.0 10.0.10-0ubuntu1~24.04.1 (Ubuntu archive, noble-updates/main)
datastore=SQLite (embedded, /opt/wexflow/Wexflow/Database/Wexflow.sqlite)
entry_point=http://<this-vm-public-ip>/admin/
built_by=cloudimg
{"message":"Wexflow Service is running..."}

Step 4: Sign in to the admin panel
Open http://<vm-ip>/admin/ in a browser. The root of the site redirects there, so http://<vm-ip>/ works too.

Sign in with the username admin and the password from /root/wexflow-credentials.txt. You land on the Dashboard, which shows live counters for pending, running, completed, failed, warning, rejected and stopped jobs, and a searchable list of recent entries.

Step 5: Browse the workflow catalogue
Choose Manager in the top navigation. This lists every workflow the engine has loaded from the embedded database, with its id, launch type, whether it is enabled, whether it needs approval, and its description.
The image ships the complete upstream example catalogue, one workflow per task type, plus the cloudimg health check. They are yours to open, copy and adapt.

To count them from the shell:
sudo sqlite3 /opt/wexflow/Wexflow/Database/Wexflow.sqlite 'select count(*) from workflows;'
ls /opt/wexflow/Wexflow/Workflows | head -6
Expected output:
120
Workflow_Approval.xml
Workflow_Approval_Import.xml
Workflow_Approval_Reject.xml
Workflow_ApproveDocument.xml
Workflow_ApproveInvoice.xml
Workflow_ApproveTimesheet.xml
Three of the upstream examples launch themselves, one at every start up, one periodically and one on a cron schedule. They are shipped disabled so a new instance does not silently execute jobs and accumulate history before you have looked at it. Step 9 shows how to switch one on.
Step 6: Run a workflow and watch it finish
In Manager, click the row for cloudimg_Health_Check to select it, then click Start.

Go back to Dashboard. The Done counter has advanced and the run appears in the entries table with a green Done status, its id, its name and the time it finished.

The workflow did real work: it created a file and copied it. Confirm on the instance:
sudo ls -l /var/lib/wexflow/healthcheck/out/
sudo journalctl -u wexflow.service --no-pager | grep cloudimg_Health_Check | tail -6
Expected output:
total 0
-rw-r--r-- 1 wexflow wexflow 0 Aug 7 21:31 cloudimg-healthcheck.txt
2026-08-07 21:29:45,309 INFO [13] - [cloudimg_Health_Check / 6] [Touch] File /var/lib/wexflow/healthcheck/src/cloudimg-healthcheck.txt created.
2026-08-07 21:29:45,309 INFO [13] - [cloudimg_Health_Check / 6] [Touch] Task finished.
2026-08-07 21:29:45,309 INFO [13] - [cloudimg_Health_Check / 6] [FilesCopier] Copying files...
2026-08-07 21:29:45,309 INFO [13] - [cloudimg_Health_Check / 6] [FilesCopier] File copied: /var/lib/wexflow/healthcheck/src/cloudimg-healthcheck.txt -> /var/lib/wexflow/healthcheck/out/cloudimg-healthcheck.txt
2026-08-07 21:29:45,309 INFO [13] - [cloudimg_Health_Check / 6] [FilesCopier] Task finished.
2026-08-07 21:29:45,310 INFO [13] - [cloudimg_Health_Check / 6] Workflow finished.
The same proof runs as a single command, which is what this instance ran on itself during first boot. It also re-proves that every published upstream default credential is refused:
sudo /usr/local/sbin/wexflow-verify-workflow.sh
Expected output:
wexflow runtime verification
ok all five published upstream default credentials rejected with 401
ok the second seeded administrator account no longer exists
ok a token forged with the published upstream signing key is rejected
ok privileged endpoints reject unauthenticated calls
ok the per-VM admin password authenticates and returns a token
ok the token opens a privileged endpoint
ok workflow 900 started, instance 237462e8-6e02-4b44-92f6-28ee42d621a2
ok the workflow produced /var/lib/wexflow/healthcheck/out/cloudimg-healthcheck.txt
ok the engine's completed-workflow counter advanced 5 -> 6
WEXFLOW_VERIFY_OK

Step 7: Build your own workflow in the designer
Choose Designer in the top navigation, click Browse, pick a workflow and click Open. The workflow renders as a diagram of its tasks in order. You can also switch the same workflow to the Graph, JSON or XML view, edit it there, and save.

New workflow starts an empty one. Give it an id that is not already in use, a name and a description, choose a launch type, then add tasks from the palette and set their options. Save writes it to the database and the engine picks it up immediately. Run starts it without leaving the designer.
The example catalogue is the fastest way to learn a task: open the example named after it, read its settings, then copy the pattern into your own workflow. Every task is also documented in XML on the instance:
ls /opt/wexflow/Documentation | head -8
cat /opt/wexflow/Documentation/FilesCopier.xml
Step 8: Review history and approvals
History lists every run the engine has ever recorded, with its status, date, workflow and description, filtered by date range and free text. Dashboard shows the same information for the current period along with the live counters.

Approvals and Records are the human in the loop side of the product. A workflow marked as an approval workflow pauses at its Approval task until somebody approves or rejects it from the Approvals page. Records attach documents to that decision, and the records hot folder at /opt/wexflow/Wexflow/Records/_HotFolder picks up files dropped into it.
Step 9: Schedule a workflow
A workflow's launch type decides when it runs. Set it in the designer, in the workflow XML, or through the API.
| Launch type | When it runs |
|---|---|
trigger |
Only when you start it, from the manager, the designer or the API |
startup |
Once, every time the engine starts |
periodic |
Every period, expressed as dd.hh:mm:ss |
cron |
On a Quartz cron expression, for example 0 0/1 * * * ? for every minute |
The three shipped examples that use the last three launch types are disabled. To look at one before enabling it:
sudo sqlite3 /opt/wexflow/Wexflow/Database/Wexflow.sqlite \
"select XML from workflows where XML like '%Workflow_Cron%' limit 1;" | head -12
To enable it, open it in the designer, tick Enabled and save. Doing it that way rather than editing the database keeps the engine's in memory schedule in step with what is stored.
File triggered workflows are the other common pattern. A workflow whose first task is FileSystemWatcher starts itself when a file appears in the folder it watches, which is how most overnight file handling is wired up. cat /opt/wexflow/Documentation/FileSystemWatcher.xml documents its options.
Step 10: Use the REST API
Everything the interface does is available over HTTP. Sign in once to get a token, then send it as a bearer token. The <WEXFLOW_ADMIN_PASSWORD> below is the password from /root/wexflow-credentials.txt.
TOKEN=$(curl -s -X POST http://127.0.0.1/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<WEXFLOW_ADMIN_PASSWORD>","stayConnected":false}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1/api/v1/status-count
echo
curl -s -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1/api/v1/search?s=cloudimg' \
| python3 -m json.tool | head -12
Expected output:
{"PendingCount":0,"RunningCount":0,"DoneCount":2,"FailedCount":0,"WarningCount":0,"DisabledCount":3,"RejectedCount":0,"StoppedCount":0}
[
{
"DbId": "120",
"Id": 900,
"InstanceId": "3a344963-066d-4280-971b-bc9d4a4752f8",
"Name": "cloudimg_Health_Check",
"FilePath": null,
"LaunchType": 1,
"IsEnabled": true,
"IsApproval": false,
"EnableParallelJobs": true,
Start a workflow and read back the engine's counters:
TOKEN=$(curl -s -X POST http://127.0.0.1/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<WEXFLOW_ADMIN_PASSWORD>","stayConnected":false}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
curl -s -X POST -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1/api/v1/start?w=900'
echo
sleep 5
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1/api/v1/status-count
echo
A browsable description of the whole API is served at http://<vm-ip>/swagger. Replace 127.0.0.1 with your instance's address to call it from your own machine.
Step 11: Change the administrator password and add users
The generated password is a starting point, not a resting place. Change it from Users in the admin panel: select admin, set a new password and save. The same page adds further users.
Wexflow has three user profiles. A super administrator sees and controls every workflow. An administrator sees only the workflows assigned to them, which is set per user on the Users page. A restricted user has no manager access at all and is used for the approvals and records side of the product.
The account named admin is the one the engine looks up at start up, so keep that name. Change its password freely.
Step 12: Serve the admin panel over TLS
The image ships on plain HTTP so it works the moment it boots, with no certificate and no domain name required. For anything beyond a trial, put a certificate in front of it. nginx is already the entry point, so this is ordinary nginx work:
- Point a DNS name at the instance and open TCP 443 in the network security group.
- Install a certificate for that name, either your own or one from a certificate authority.
- Add a
listen 443 ssl;server block to/etc/nginx/sites-available/wexflowwith your certificate paths, keeping the samelocationblocks, and redirect port 80 to it. - Reload with
sudo nginx -t && sudo systemctl reload nginx.
The admin single page application addresses the API on the same origin it was loaded from, so it follows the change with no further configuration.
Server components
| Component | Version | Purpose |
|---|---|---|
| Wexflow | 10.0 | The workflow engine, admin panel and REST API |
| ASP.NET Core runtime | 10.0 | The runtime the engine is built against, from the Ubuntu archive |
| nginx | 1.24 | Reverse proxy and the single public entry point on port 80 |
| SQLite | 3.45 | The embedded database holding workflows, users, entries and records |
| Ubuntu Server | 24.04 LTS | Operating system, fully patched at build time |
Filesystem layout
| Path | Size | Purpose |
|---|---|---|
/ |
29 GB | Root filesystem |
/boot |
881 MB | Operating system kernel files |
/boot/efi |
100 MB | UEFI boot partition (Gen2 Hyper V) |
/mnt |
varies | Azure temporary resource disk |
Key directories:
| Path | Purpose |
|---|---|
/opt/wexflow |
Application root |
/opt/wexflow/Wexflow.Server |
The engine, its appsettings.json and the admin panel host |
/opt/wexflow/Admin |
The admin single page application |
/opt/wexflow/Wexflow/Database |
The embedded SQLite database |
/opt/wexflow/Wexflow/Workflows |
Workflow definitions on disk |
/opt/wexflow/Wexflow/Records |
Records, including the _HotFolder watched folder |
/opt/wexflow/Wexflow/Temp |
Scratch space used during a run |
/opt/wexflow/Documentation |
One XML reference page per task type |
/opt/wexflow/WexflowTesting |
Sample input files the example workflows use |
/var/lib/wexflow |
Working directory of the service account |
/var/log/wexflow |
Engine log |
Managing the services
sudo systemctl status wexflow.service --no-pager | head -6
sudo systemctl restart wexflow.service
sudo systemctl reload nginx
Expected output:
● wexflow.service - Wexflow workflow engine
Loaded: loaded (/etc/systemd/system/wexflow.service; enabled; preset: enabled)
Active: active (running) since Fri 2026-08-07 21:31:08 UTC; 30s ago
Docs: https://www.cloudimg.co.uk/guides/wexflow-on-ubuntu-24-04-azure/
Main PID: 9201 (dotnet)
Tasks: 22 (limit: 4666)
Both units are enabled, so the appliance comes back on its own after a reboot.
Scripts and log files
| Path | Purpose |
|---|---|
/usr/local/sbin/wexflow-firstboot.sh |
First boot bootstrap: mints this instance's secrets, then releases the services |
/usr/local/sbin/wexflow-verify-workflow.sh |
The runtime proof used in Step 6, safe to re-run at any time |
/usr/local/lib/cloudimg/wexflow_pwhash.py |
The password hashing helper first boot uses |
/var/log/cloudimg-firstboot.log |
What first boot did on this instance |
/var/log/wexflow/Wexflow.log |
Engine log, one file per day |
/var/log/nginx/access.log |
Proxy access log |
/etc/nginx/sites-available/wexflow |
The reverse proxy configuration |
sudo tail -n 20 /var/log/cloudimg-firstboot.log
sudo tail -n 20 /var/log/wexflow/Wexflow.log
On startup
The first time an instance boots, wexflow-firstboot.service runs once. It generates a unique administrator password, a unique token signing key and a unique certificate passphrase, writes the password into /root/wexflow-credentials.txt with mode 0600, and only then creates the marker that releases the engine and nginx.
Both wexflow.service and nginx.service carry ConditionPathExists on that marker, so until first boot has finished nothing is listening on port 80 at all. There is no window in which a newly launched instance is reachable with a credential that came from the image.

First boot then proves the result before handing the instance over: every published upstream default credential is refused, a token forged with the published upstream signing key is refused, the new password works, and a real workflow runs to completion. If any of that fails the service is left down rather than handed over broken.
sudo systemctl is-active wexflow-firstboot.service
sudo test -f /var/lib/cloudimg/wexflow-firstboot.done && echo "first boot complete"
Expected output:
active
first boot complete
Troubleshooting
The admin panel does not load. Check that first boot finished and that both services are up:
sudo systemctl is-active wexflow-firstboot.service wexflow.service nginx.service
sudo test -f /var/lib/cloudimg/wexflow-bootstrap-ready && echo "bootstrap ready marker present"
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/healthz
All three units report active, the marker is present and /healthz returns 200. If the units are inactive and the marker is missing, first boot did not finish: read /var/log/cloudimg-firstboot.log and sudo journalctl -u wexflow-firstboot.service.
It loads on the instance but not from my browser. That is the network security group. Port 80 has to be open inbound from wherever you are browsing. Port 8000 is the engine behind the proxy and should stay closed.
The password in the credentials file is rejected. The file is rewritten only on the very first boot. If the password was changed afterwards from the Users page, the file still shows the original one. Change it back from an account that can still sign in, or see the next item.
Nobody can sign in at all. Reset the account from the instance. This writes a new hash directly into the embedded database using the same scheme the engine uses. Replace <new-password> with the password you want, and note that it is read on standard input rather than passed as an argument so it never reaches the process list or the journal:
sudo systemctl stop wexflow.service
NEWHASH="$(printf '%s' '<new-password>' | sudo /usr/local/lib/cloudimg/wexflow_pwhash.py hash)"
sudo sqlite3 /opt/wexflow/Wexflow/Database/Wexflow.sqlite \
"update users set PASSWORD='${NEWHASH}' where USERNAME='admin';"
sudo systemctl start wexflow.service
Sign in with the new password, then update /root/wexflow-credentials.txt or your own password store so the two agree again.
A workflow finished with a warning or failed. Open it from History, or read the engine log. Every task logs what it did and why it stopped:
sudo tail -n 40 /var/log/wexflow/Wexflow.log
A workflow cannot write where I told it to. The engine runs as the unprivileged wexflow account, not as root. It can write inside /opt/wexflow/Wexflow, /opt/wexflow/WexflowTesting and /var/lib/wexflow. For anywhere else, create the directory and give it to the service account:
sudo install -d -o wexflow -g wexflow -m 0750 /srv/my-workflow-data
Signed out pages work but signed in pages return 502. That is a proxy buffer that is too small for the signed in session cookie. The shipped configuration already raises it; if you have replaced /etc/nginx/sites-available/wexflow, keep the proxy_buffer_size 32k; and proxy_buffers 8 32k; lines.
Checking the appliance as a whole. The single command from Step 6 re-proves the sign in path, the credential hygiene and a real workflow run in one go:
sudo /usr/local/sbin/wexflow-verify-workflow.sh
Security notes

- No shared or published credential ships in the image. Upstream seeds two administrator accounts with the same published password. The second account is removed from the image entirely, and the stored hash of the remaining
adminaccount is replaced at build time with a value nobody records, so the image contains no hash of any published password. - The token signing key is per instance. Wexflow signs its session tokens with an HMAC key from its settings file. The published upstream value would let anyone mint a valid token for any user without ever touching the sign in page, so it is replaced with a non functional placeholder in the image and generated freshly on every first boot. Step 6's verification proves a token signed with the published key is refused.
- Nothing listens until the instance has its own secrets. Both services are gated on a marker that first boot creates only after every per instance secret is written and verified.
- The engine runs unprivileged. It runs as the
wexflowsystem account with no login shell andNoNewPrivileges, not as root. - Change the generated password from the Users page, and add a separate account per person rather than sharing
admin. - Restrict the network security group. Allow SSH only from your management network, and port 80 only from where the admin panel needs to be reachable. Leave port 8000 closed.
- Put TLS in front of it before using it over anything other than a trusted network, as in Step 12. Session tokens travel in a cookie, and on plain HTTP that cookie is readable on the wire.
- Wexflow is an automation engine, and that is the point. Its task library deliberately launches processes, runs scripts and moves files. Treat access to the designer as equivalent to shell access on the instance, and give the designer only to people you would give that to.
- Keep the operating system patched. Unattended security updates are enabled. Reboot when a kernel update lands.
Support
- Deployment guides: www.cloudimg.co.uk/guides
- Product catalogue: www.cloudimg.co.uk/products
- 24/7 support: support@cloudimg.co.uk
Wexflow is a trademark of its respective owners. cloudimg is not affiliated with, endorsed by or sponsored by the Wexflow project; the name is used nominatively to identify the open source software packaged in this image. Wexflow is distributed under the MIT licence, and the verbatim licence text is retained on the image at /opt/wexflow/LICENSE.txt.