Liwan
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. A bundled bot and referrer-spam list keeps automated traffic out of your figures.
The SecureAnyCloud 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, and it runs under systemd. Both datastores live on a dedicated Azure data disk mounted at /var/lib/liwan. The disk is separate from the OS disk and is re-provisioned with every VM. The image is backed by 24/7 SecureAnyCloud support.
What you get:
- Liwan 1.6.0 (single static Rust binary) at
/opt/liwan/liwan, pinned and verified by sha256 against the upstream release artifact - An nginx reverse proxy on
:80in front of the Liwan server on127.0.0.1:9042 - A dedicated Azure data disk at
/var/lib/liwanthat holds 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.serviceandnginx.serviceas systemd units, enabled and active- An Ubuntu 24.04 LTS base, patched at build time, with
unattended-upgradesenabled - 24/7 SecureAnyCloud 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. Whoever opens that link first becomes the administrator. On a public cloud VM, that is a race you do not want to run.
The SecureAnyCloud image closes the race. On the 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 ever starts. 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 that theadminaccount exists with the admin role. The cleanup step of the build removes the marker before the image is captured. The service therefore cannot start on a customer VM until the first boot of that VM 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 SecureAnyCloud.
- Select Create.
- Choose your subscription, resource group and region.
- Choose the 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 image terms accept --urn sac1647283583153:liwan:default:latest
az vm create \
--resource-group liwan-rg \
--name liwan-vm \
--image sac1647283583153: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 900Note the publicIpAddress in the output. This is the address of your dashboard.
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/sac/data/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 [::]:80Read the listener list 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 the first boot of this VM, and only root can read it:
sudo cat /root/liwan-credentials.txtThe output shows 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>To confirm that the unauthenticated setup path is closed on your own instance, run:
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.jsonExpected output:
POST /api/dashboard/auth/setup -> HTTP 401Step 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"]Add the following line to every page of your website. Replace <vm-ip> with the address of your VM:
<script defer src="http://<vm-ip>/script.js" data-entity="my-site"></script>Liwan itself serves the script, so no third-party script runs on your pages and no visitor data leaves your VM. The script is about 1.7KB (under 1KB compressed) and 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. To prove the ingestion path end to end from the VM itself, send 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) sac-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/eventExpected output:
event -> HTTP 200Then 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):
1In the browser, the dashboard breaks the 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 (anyone who holds the link can view it, but it is hidden from the index). Change this in the settings of the project in the web interface.
Add, remove and re-scope user accounts from the Users section of the web interface. Prefer this path, 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 liwanExpected output:
Users: - admin (admin) activeTo create a further account, use add-user <username> <password> between the same stop and start commands. Append --admin true for a full administrator. --log-level error 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. You can resize or snapshot the disk 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/fstabExpected 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 2liwan-app.sqlite holds users, projects, entities and settings. liwan-events.duckdb holds the pageview events themselves. DuckDB is a columnar engine, which lets Liwan aggregate millions of events quickly for a dashboard query.
Changing the administrator password
The simplest way is the Users section of the web interface. The change applies immediately and needs no downtime.
From the command line, remember the DuckDB lock described above: stop the service, change the password, and start the service 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 liwanAfterwards, update /root/liwan-credentials.txt if you want it to stay accurate.
Configuration
Liwan reads /etc/liwan/liwan.config.toml. This image sets the following values:
grep -E '^(base_url|listen|data_dir|trusted_proxies|trusted_headers)' /etc/liwan/liwan.config.tomlExpected 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 the address of this VM 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, and a barelisten = 9042would bind every interface.trusted_headers/trusted_proxiesare deliberately narrow. The Liwan 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 honored. nginx overwrites that header with the real peer address instead of appending to whatever the client sent.
Restart after editing:
sudo systemctl restart liwan systemctl is-active liwanCountry and city reporting (optional)
The map and the country table of the dashboard 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. This 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 the database.
Backing up and restoring
Stop the service so that both datastores are consistent, copy them, and then start the service 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.gzTo restore, stop the service, replace both files (keeping liwan:liwan ownership), and start the service again.
Security updates
The image is patched at build time and ships 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 --showExpected output on a VM deployed soon after the build: no held packages, no swap, and nothing outstanding unless updates were released since the build.
0 enabledEnabling HTTPS with Let's Encrypt
Point a DNS A record at your VM and open port 443. Then run:
sudo apt-get update && sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d analytics.your-domain.comThen update base_url so that 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 liwanAlso change the src in your tracking snippet to the HTTPS URL.
Upgrading Liwan
Liwan is a single binary, so an upgrade is a download and a restart. Check the releases page for the current version and then run:
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 liwanBack up the data directory first. A binary swap leaves your datastores, users and projects untouched.
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 -20liwan.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 -20You 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. This is intended. Liwan binds loopback only, so 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 SecureAnyCloud image comes with 24/7 support. Contact sales@secureanycloud.com 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.
Deploy on Azure
Launch Liwan on Ubuntu 24.04 LTS on Azure with 24/7 support from SecureAnyCloud.
View this image
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com