Liwan on Ubuntu 24.04 on Azure User Guide
Overview
Liwan is a lightweight, privacy-first web analytics server. It measures pageviews, unique visitors, referrers, entry and exit pages, UTM campaigns, browsers, platforms and device types, and presents them on a dashboard that updates in real time. Tracking is cookieless and uses no persistent or cross-site identifiers, so it needs no cookie banner, and a bundled bot and referrer-spam list keeps automated traffic out of your figures.
The cloudimg image delivers Liwan 1.6.0 fully installed and reverse-proxied with nginx. Liwan is a single statically-linked Rust binary with embedded datastores - SQLite for application state and DuckDB for the event store - running under systemd. Both datastores live on a dedicated Azure data disk mounted at /var/lib/liwan, separate from the OS disk and re-provisioned with every VM. Backed by 24/7 cloudimg support.
What is included:
- Liwan 1.6.0 (single static Rust binary) at
/opt/liwan/liwan, pinned and verified by sha256 against the upstream release artifact - nginx reverse proxy on
:80in front of the Liwan server on127.0.0.1:9042 - A dedicated Azure data disk at
/var/lib/liwanholding the SQLite and DuckDB datastores, separate from the OS disk and independently resizable - Secure by default: a unique 32-character administrator password is generated on first boot into a root-only file, so no shared or default credential ships in the image
liwan.service+nginx.serviceas systemd units, enabled and active- A fully patched Ubuntu 24.04 LTS base with
unattended-upgradesenabled - 24/7 cloudimg support
Secure by default - a unique administrator on first boot
Upstream Liwan has no default account at all. Instead, when it starts with an empty user table it mints a one-time "onboarding token" and prints a /setup link, and whoever opens that link first becomes the administrator. On a public cloud VM that is a race you do not want to run.
The cloudimg image closes it. On the very first boot, liwan-firstboot.service generates a unique 32-character password for your virtual machine and creates the admin account with it before the Liwan server is ever started. Because a user then already exists, Liwan never mints an onboarding token, and POST /api/dashboard/auth/setup rejects every request with 401 for the life of the instance.
Two independent guards make it impossible for this image to serve an unclaimed instance:
liwan.serviceis gated on a bootstrap marker. The unit carriesConditionPathExists=/var/lib/liwan/.bootstrap-ready, and onlyliwan-firstboot.servicecreates that marker - after it has confirmed theadminaccount exists with the admin role. The build's cleanup step removes the marker before the image is captured, so the service physically cannot start on a customer VM until that VM's own first boot has provisioned its own credential.- The account database never ships. The SQLite and DuckDB files are wiped before capture, so the image contains no user record of any kind.
The password is written to /root/liwan-credentials.txt (mode 0600, readable only by root) and never appears in the image.

Prerequisites
- An Azure subscription
- Azure CLI installed and signed in (
az login), if you deploy from the command line - An SSH key pair for VM access
- A network security group that publishes port 80 (dashboard and tracking endpoint) and port 22 (SSH). Liwan itself listens only on
127.0.0.1:9042and is not reachable from outside the VM.
Step 1 - Deploy from the Azure Marketplace
- In the Azure portal, search the Marketplace for Liwan on Ubuntu 24.04 LTS by cloudimg.
- Select Create.
- Choose your subscription, resource group and region.
- Recommended size: Standard_B2s (2 vCPU, 4 GiB RAM).
- Set the administrator username to
azureuserand upload your SSH public key. - On the Networking tab, allow inbound HTTP (80) and SSH (22).
- Select Review + create, then Create.
Step 2 - Deploy from the Azure CLI
az group create --name liwan-rg --location eastus
az vm create \
--resource-group liwan-rg \
--name liwan-vm \
--image cloudimg:liwan:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
az vm open-port --resource-group liwan-rg --name liwan-vm --port 80 --priority 900
Take note of the publicIpAddress in the output - that is where your dashboard will be.
Step 3 - Connect to your VM
ssh azureuser@<vm-ip>
Step 4 - Confirm the services are running
systemctl is-active liwan.service nginx.service
test -f /var/lib/cloudimg/liwan-firstboot.done && echo "firstboot: complete"
ss -ltn | grep -E ':80 |:9042 ' | awk '{print $1, $4}'
Expected output:
active
active
firstboot: complete
LISTEN 0.0.0.0:80
LISTEN 127.0.0.1:9042
LISTEN [::]:80
That listener list is worth reading closely. nginx is on 0.0.0.0:80, so it accepts traffic from the network. Liwan is on 127.0.0.1:9042, so it accepts traffic only from the VM itself - nginx is the single entry point, and there is no second door on port 9042 to forget about.

Step 5 - Retrieve your administrator password
The password was generated on this VM's first boot and is readable only by root:
sudo cat /root/liwan-credentials.txt
You will see the sign-in URL, the username admin, and the unique password for this machine:
LIWAN_URL=http://<vm-ip>/
liwan.username=admin
liwan.password=<unique-32-character-password-for-this-VM>

You can confirm the unauthenticated setup path really is closed on your own instance:
printf '%s' '{"token":"any-token","username":"intruder","password":"intruder-password"}' > /tmp/liwan-setup-probe.json
curl -s -o /dev/null -w 'POST /api/dashboard/auth/setup -> HTTP %{http_code}\n' \
-H 'Content-Type: application/json' \
--data-binary @/tmp/liwan-setup-probe.json \
http://127.0.0.1/api/dashboard/auth/setup
rm -f /tmp/liwan-setup-probe.json
Expected output:
POST /api/dashboard/auth/setup -> HTTP 401
Step 6 - Sign in to Liwan
Open http://<vm-ip>/ in a browser and sign in with admin and the password from Step 5.
You can also verify the credential from the command line:
LIWAN_PASS='<LIWAN_PASSWORD>'
JAR=$(mktemp)
curl -s -o /dev/null -w 'login -> HTTP %{http_code}\n' -c "$JAR" \
-H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$LIWAN_PASS" '{username:"admin",password:$p}')" \
http://127.0.0.1/api/dashboard/auth/login
curl -s -b "$JAR" http://127.0.0.1/api/dashboard/auth/me | jq -c .
rm -f "$JAR"
Expected output:
login -> HTTP 200
{"username":"admin","role":"admin"}

Step 7 - Add your first website
Liwan separates entities (a website you measure) from projects (a dashboard grouping one or more entities). Create one of each, then point your site at the tracking script.
LIWAN_PASS='<LIWAN_PASSWORD>'
JAR=$(mktemp)
curl -s -o /dev/null -c "$JAR" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$LIWAN_PASS" '{username:"admin",password:$p}')" \
http://127.0.0.1/api/dashboard/auth/login
curl -s -o /dev/null -b "$JAR" -H 'Content-Type: application/json' \
--data-binary '{"id":"my-site","displayName":"my-site.example","projects":[]}' \
http://127.0.0.1/api/dashboard/entity
curl -s -o /dev/null -b "$JAR" -H 'Content-Type: application/json' \
--data-binary '{"displayName":"my-site.example","public":false,"entities":["my-site"]}' \
http://127.0.0.1/api/dashboard/project/my-site
curl -s -b "$JAR" http://127.0.0.1/api/dashboard/entities | jq -c '[.entities[].id]'
rm -f "$JAR"
Expected output (the entity you just created is listed):
["my-site"]
Now add this one line to every page of your website, replacing <vm-ip> with your VM's address:
<script defer src="http://<vm-ip>/script.js" data-entity="my-site"></script>
The script is served by Liwan itself, so no third-party script runs on your pages and no visitor data leaves your VM. It is about 1.7KB, under 1KB compressed, and it sends its events to /api/event on the origin it was loaded from. Set data-api explicitly only if you serve the script from a different host.
Step 8 - Watch traffic arrive
Any pageview from your site now shows up on the dashboard within seconds. You can prove the ingestion path end to end from the VM itself by sending a pageview exactly as a browser would:
curl -s -o /dev/null -w 'event -> HTTP %{http_code}\n' \
-H 'Content-Type: application/json' \
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) cloudimg-check' \
--data-binary '{"entity_id":"my-site","name":"pageview","url":"https://my-site.example/welcome","referrer":"https://news.ycombinator.com/"}' \
http://127.0.0.1/api/event
Expected output:
event -> HTTP 200
Then confirm the dashboard counts it:
LIWAN_PASS='<LIWAN_PASSWORD>'
JAR=$(mktemp)
curl -s -o /dev/null -c "$JAR" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$LIWAN_PASS" '{username:"admin",password:$p}')" \
http://127.0.0.1/api/dashboard/auth/login
sleep 3
S=$(date -u -d '1 hour ago' '+%Y-%m-%dT%H:%M:%SZ'); E=$(date -u -d '1 hour' '+%Y-%m-%dT%H:%M:%SZ')
curl -s -b "$JAR" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg s "$S" --arg e "$E" '{range:{start:$s,end:$e},filters:[]}')" \
http://127.0.0.1/api/dashboard/project/my-site/stats | jq -c '.stats.totalViews'
rm -f "$JAR"
Expected output (at least the pageview you just sent):
1
In the browser, the dashboard breaks that traffic down by page, referrer, platform, browser and device type. Use the range selector in the top right to switch between Today, Last 7 Days, Last 30 Days and custom ranges.

Managing projects and users
The signed-in overview lists every project with its headline metrics, so a single Liwan instance can serve several sites.

Projects can be private (the default), public (anyone can view the dashboard without signing in) or unlisted (viewable by anyone holding the link, but hidden from the index). Change this from the project's settings in the web interface.
Add, remove and re-scope user accounts from the Users section of the web interface. That is the path to prefer, because it needs no downtime.
There is also a command-line interface, with one important constraint: the liwan CLI takes an exclusive lock on the DuckDB event store, so it cannot run while the server is running. Stop the service first, run the command, then start it again:
sudo systemctl stop liwan
sudo runuser -u liwan -- /opt/liwan/liwan --log-level error --config /etc/liwan/liwan.config.toml users
sudo systemctl start liwan
systemctl is-active liwan
Expected output:
Users:
- admin (admin)
active
Use add-user <username> <password> in the same stop/start sandwich to create a further account, appending --admin true for a full administrator. --log-level error just keeps the routine startup log lines out of the output.
The data disk
Both datastores live on a dedicated Azure data disk, so your analytics history is independent of the OS disk and can be resized or snapshotted on its own.
findmnt -no SOURCE,TARGET,FSTYPE,SIZE /var/lib/liwan
ls -lh /var/lib/liwan/liwan-app.sqlite /var/lib/liwan/liwan-events.duckdb
grep /var/lib/liwan /etc/fstab
Expected output:
/dev/sdc /var/lib/liwan ext4 29.4G
-rw-r--r-- 1 liwan liwan 76K Jul 25 23:31 /var/lib/liwan/liwan-app.sqlite
-rw-r--r-- 1 liwan liwan 780K Jul 25 23:31 /var/lib/liwan/liwan-events.duckdb
UUID=<disk-uuid> /var/lib/liwan ext4 defaults,nofail 0 2
liwan-app.sqlite holds users, projects, entities and settings. liwan-events.duckdb holds the pageview events themselves - DuckDB is a columnar engine, which is what lets Liwan aggregate millions of events for a dashboard query quickly.

Changing the administrator password
The simplest way is the Users section of the web interface - it applies immediately and needs no downtime.
From the command line, remember the DuckDB lock described above: stop the service, change the password, start it again.
sudo systemctl stop liwan
sudo runuser -u liwan -- /opt/liwan/liwan --log-level error --config /etc/liwan/liwan.config.toml \
update-password admin '<new-password>'
sudo systemctl start liwan
Update /root/liwan-credentials.txt afterwards if you want it to stay accurate.
Configuration
Liwan reads /etc/liwan/liwan.config.toml. The settings this image sets:
grep -E '^(base_url|listen|data_dir|trusted_proxies|trusted_headers)' /etc/liwan/liwan.config.toml
Expected output:
base_url = "http://<vm-ip>/"
listen = "127.0.0.1:9042"
data_dir = "/var/lib/liwan"
trusted_headers = ["x-forwarded-for"]
trusted_proxies = ["127.0.0.1"]
base_urlis set to this VM's address on first boot. Change it when you put a domain name in front (see HTTPS below). It must match the scheme you actually serve on: Liwan marks its session cookieSecurewhenbase_urlstarts withhttps, so anhttpsvalue on a plain-HTTP deployment produces a sign-in that appears to succeed but never stays signed in.listenbinds the backend to loopback. The socket-address form is what constrains it; a barelisten = 9042would bind every interface.trusted_headers/trusted_proxiesare deliberately narrow. Liwan's own defaults trust every forwarded header from every peer, which would let a visitor forge their own source IP. Here only the local nginx is trusted, and onlyX-Forwarded-Foris honoured - and nginx overwrites that header with the real peer address rather than appending to whatever the client sent.
Restart after editing:
sudo systemctl restart liwan
systemctl is-active liwan
Country and city reporting (optional)
The dashboard's map and country table need a MaxMind GeoLite2 database, which requires a free MaxMind account. No database ships with this image, so until you add one every visit is reported as Unknown - which is why the map is empty on a fresh instance.
To enable it, add your credentials to /etc/liwan/liwan.config.toml and restart:
[geoip]
maxmind_account_id = "YOUR_ACCOUNT_ID"
maxmind_license_key = "YOUR_LICENSE_KEY"
maxmind_edition = "GeoLite2-City"
Liwan downloads the database into the data directory and keeps it up to date. Everything else on the dashboard works without it.
Backing up and restoring
Stop the service so both datastores are consistent, copy them, then start it again:
sudo systemctl stop liwan
sudo tar czf /var/tmp/liwan-backup.tar.gz -C /var/lib/liwan liwan-app.sqlite liwan-events.duckdb
sudo systemctl start liwan
ls -lh /var/tmp/liwan-backup.tar.gz
To restore, stop the service, replace both files (keeping liwan:liwan ownership), and start it again.
Security updates
The image ships fully patched with unattended-upgrades enabled, so security updates continue to arrive on your VM.
apt-mark showhold
apt-get -s -o APT::Get::Always-Include-Phased-Updates=true dist-upgrade | grep -c '^Inst ' || true
systemctl is-enabled unattended-upgrades.service
swapon --show
Expected output on a freshly deployed VM (no held packages, nothing outstanding, no swap):
0
enabled

Enabling HTTPS with Let's Encrypt
Point a DNS A record at your VM, open port 443, then:
sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d analytics.your-domain.com
Then update base_url so sessions and the tracking script use the new address:
sudo sed -i 's|^base_url = .*|base_url = "https://analytics.your-domain.com/"|' /etc/liwan/liwan.config.toml
sudo systemctl restart liwan
Remember to change the src in your tracking snippet to the HTTPS URL as well.
Upgrading Liwan
Liwan is a single binary, so an upgrade is a download and a restart. Check the releases page for the current version, then:
sudo systemctl stop liwan
curl -fsSL -o /tmp/liwan.tar.gz https://github.com/explodingcamera/liwan/releases/download/liwan-v<version>/liwan-x86_64-unknown-linux-musl.tar.gz
sudo tar xzf /tmp/liwan.tar.gz -C /opt/liwan
sudo chmod 0755 /opt/liwan/liwan
sudo systemctl start liwan
Back up the data directory first. Your datastores, users and projects are untouched by a binary swap.
Troubleshooting
The dashboard does not load. Check both units and the reverse proxy:
systemctl is-active liwan nginx
sudo journalctl -u liwan --no-pager | tail -20
liwan.service will not start and reports a failed condition. The unit is gated on /var/lib/liwan/.bootstrap-ready, which first boot creates. If it is missing, first boot did not finish:
sudo journalctl -u liwan-firstboot --no-pager | tail -20
You cannot stay signed in. Check that base_url matches the scheme you are browsing with - an https value served over plain HTTP marks the session cookie Secure, so the browser never sends it back.
Port 9042 refuses connections from outside. That is intended. Liwan binds loopback only; reach it through nginx on port 80.
Events return HTTP 400. The url field must be a full, valid URL, and entity_id must match an entity that exists.
Support
Every cloudimg image comes with 24/7 support. Contact support@cloudimg.co.uk with your Azure subscription ID and the VM name.
Liwan is open source under the Apache License 2.0. The bundled browser tracking script is MIT licensed, and the bundled bot, referrer-spam and geography data sets carry their own notices - see LICENSE.md and NOTICE.md upstream.