AirTrail on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and configuration of AirTrail on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. AirTrail is an open source, self-hosted flight tracker and travel log. You record the flights you take, with route, airline, aircraft and seat, and AirTrail draws each journey as a great-circle arc on an interactive world map so your travel history builds into a picture you can explore. Alongside the map it keeps running statistics for distance flown, time in the air, airports and countries visited, airlines and aircraft types. Flights can be added by hand, looked up from a flight number, or imported in bulk, and several people can share one instance, each with their own account and their own map.
The cloudimg image ships the free and open source, GPL-3.0 licensed AirTrail application, run the officially supported way as the upstream container pinned by image digest, with PostgreSQL 16 as its database. Both images are captured into the VM, so your instance starts in seconds. Upstream AirTrail makes the first visitor to a new instance its owner, which is a real risk for a VM with a public IP; this image closes that window completely. On first boot, before the public port is ever opened, the instance generates a per-VM PostgreSQL password and claims the owner account itself with a per-VM random password, written to a root-only file. Backed by 24/7 cloudimg support.
AirTrail is a trademark of its respective owner. This image is produced by cloudimg and is not affiliated with, endorsed by, or sponsored by the AirTrail project. It ships the free and open source self-hosted software, unmodified.

What is included:
- AirTrail v3.11.1 (the GPL-3.0 licensed SvelteKit flight tracker), pinned by image digest
- PostgreSQL 16, created empty on first boot and stored on a persistent volume, reachable only from the application container
- Docker Engine running both containers on a private network, with the app published to loopback only
- Host nginx as the single public listener on port
80, with header buffers sized for authenticated responses airtrail.service,airtrail-firstboot.serviceandairtrail-postboot.serviceas systemd units, enabled and active on boot- A per-VM PostgreSQL password and a per-VM owner account password, generated on first boot and never baked into the image
- The owner account claimed during bootstrap while port
80is still closed, so a passer-by can never claim the instance - No open self-registration: further accounts are created by the owner from Settings
- No default login, no shipped secret, no bundled third-party API key and an empty database on first boot
- Ubuntu 24.04 LTS base with latest security patches applied at build time
- Azure Linux Agent for seamless cloud integration and SSH key injection
- 24/7 cloudimg support with guaranteed 24 hour response SLA
Prerequisites
- Active Azure subscription, SSH public key, VNet + subnet in target region
- Subscription to the AirTrail listing on Azure Marketplace
Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM) comfortably exceeds AirTrail's stated minimum of 2 CPU cores, 2 GB RAM and 10 GB disk, and is a sensible starting point for a person, a household or a small team. For many concurrent users use Standard_D2s_v5 or larger. NSG inbound: allow 22/tcp from your management network and 80/tcp for the web app from the networks that use it.
Step 1: Deploy from the Azure Portal
Sign in to the Azure Portal, choose Create a resource, search the Marketplace for AirTrail by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTP (80). Then Review + create and Create.
Step 2: Deploy from the Azure CLI
RG="airtrail-prod"; LOCATION="eastus"; VM_NAME="airtrail-01"
GALLERY_IMAGE_ID="/subscriptions/<sub-id>/resourceGroups/azure-cloudimg/providers/Microsoft.Compute/galleries/cloudimgGallery/images/airtrail-ubuntu-24-04/versions/<version>"
SSH_KEY="$(cat ~/.ssh/id_rsa.pub)"
az group create --name "$RG" --location "$LOCATION"
az network vnet create -g "$RG" --name airtrail-vnet --address-prefix 10.100.0.0/16 --subnet-name airtrail-subnet --subnet-prefix 10.100.1.0/24
az network nsg create -g "$RG" --name airtrail-nsg
az network nsg rule create -g "$RG" --nsg-name airtrail-nsg --name allow-ssh --priority 100 \
--source-address-prefixes "<your-mgmt-cidr>" --destination-port-ranges 22 --access Allow --protocol Tcp
az network nsg rule create -g "$RG" --nsg-name airtrail-nsg --name allow-http --priority 110 \
--destination-port-ranges 80 --access Allow --protocol Tcp
az vm create -g "$RG" --name "$VM_NAME" --image "$GALLERY_IMAGE_ID" \
--size Standard_B2s --storage-sku StandardSSD_LRS \
--admin-username azureuser --ssh-key-values "$SSH_KEY" \
--vnet-name airtrail-vnet --subnet airtrail-subnet --nsg airtrail-nsg --public-ip-sku Standard
Replace <sub-id> and <version> with your subscription id and the image version shown on the listing, and <your-mgmt-cidr> with the network you administer from.
Step 3: First boot and per-instance credentials
On the first boot of each VM, AirTrail generates its own per-instance secrets and claims its own owner account before the web port is opened. Nothing is baked into the image. Give the instance a minute after first boot, then SSH in and read the credentials file (it is 0600 root:root):
sudo cat /root/airtrail-credentials.txt

The file contains everything unique to this instance:
AIRTRAIL_USERNAME/AIRTRAIL_PASSWORD— the owner login you use in the web UI. The owner has full access, including user management.AIRTRAIL_URL— the web address of this instance, resolved from its public IP on first boot.POSTGRES_PASSWORD— the database role password. PostgreSQL is not published to any host port, so this is only used inside the private container network.
Confirm the services and containers are healthy:
sudo systemctl is-active docker airtrail-firstboot airtrail airtrail-postboot nginx
active
active
active
active
active
sudo docker compose -f /opt/airtrail/docker-compose.yml ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'
SERVICE STATUS PORTS
airtrail Up 8 minutes (healthy) 127.0.0.1:3000->3000/tcp
db Up 8 minutes (healthy) 5432/tcp
The application container publishes to loopback only and PostgreSQL has no host port at all; host nginx on port 80 is the single public door.
Step 4: The security model
Upstream AirTrail guides the first person who opens a new instance through creating the owner account. On a VM with a public IP that is a real risk, so cloudimg removes it in two independent ways.
First, airtrail-postboot.service claims the owner account itself, over the loopback port, with a password generated for this VM. AirTrail's own setup endpoint then refuses forever with Owner already exists, which is enforced by the application's code rather than by a configuration flag. Second, host nginx — the only public listener — does not start until that has happened: it is gated on a bootstrap-ready marker that post-boot creates only after the owner exists. Port 80 is therefore closed for the whole bootstrap window.
The database password is generated on first boot too, before the stack can start, so the upstream sample value (DB_PASSWORD=password) never exists on your VM. A start-up guard refuses to launch the application at all if that password is empty, too short or a known default.

ss -tln | grep -E ':(80|3000|5432) ' | awk '{print $1, $4}'
LISTEN 127.0.0.1:3000
LISTEN 0.0.0.0:80
LISTEN [::]:80
Cross-site form submissions are rejected, and map data that belongs to a signed-in user is not readable anonymously:
curl -s -o /dev/null -w 'form POST without an Origin header -> %{http_code}\n' -X POST http://127.0.0.1/api/users/setup --data-urlencode username=probe --data-urlencode password=probeprobe1 --data-urlencode displayName=probe
curl -s -o /dev/null -w 'authenticated-only map API (anonymous) -> %{http_code}\n' http://127.0.0.1/api/map-styles/openaip/tiles/1/1/1
form POST without an Origin header -> 403
authenticated-only map API (anonymous) -> 401
Exactly one account exists on a fresh instance, and it is the owner:
sudo docker exec airtrail-db psql -w -U airtrail -d airtrail -tAc "SELECT username, role FROM \"user\"" </dev/null
admin|owner
Step 5: Verify the end-to-end round-trip
The image ships a prover that exercises the whole security model through the public front door: it confirms the app answers, that anonymous visitors are sent to the login page, that cross-site form posts are refused, that the owner seat is taken, that a wrong password is rejected, and that the per-instance password logs in and reads back real authenticated data.
sudo /usr/local/sbin/airtrail-roundtrip.sh

Step 6: Sign in to the web app
Open http://<vm-ip>/ in your browser and sign in with the AIRTRAIL_USERNAME and AIRTRAIL_PASSWORD from the credentials file.

Once signed in, the map is the home view. Every flight you log is drawn as a great-circle arc between its two airports, so your travel history accumulates into a picture of where you have been. The controls on the right zoom, re-centre, switch the base map and filter which flights are shown; the dock along the bottom opens the flight list, statistics and settings.

Choose Statistics from the dock for the totals AirTrail derives from your logged flights: number of flights, distance flown expressed as a fraction of the way around the globe, total time in the air, and how many airports and countries you have visited, plus breakdowns by seat class, cabin, trip reason, continent and airline.

List flights shows every flight in date order with its airline, flight number, route, duration and seat. Use Add flight in the dock to record a new one — you can enter it by hand or look it up by flight number and date.

Adding other people
There is no open self-registration on this image, so nobody can create an account on your instance. To add a family member or colleague, sign in as the owner, open Settings from the dock, choose Users, and add the account there. Each person gets their own flights, their own map and their own statistics.
Map data and optional integrations
The base map is served by CARTO's public basemap CDN, and airport locations come from a map data file baked into the image. Neither needs an API key, and no map key is shipped or required — but the base map tiles are fetched by the browser from the public internet, so a client with no outbound internet access will see the flight arcs draw over an empty background rather than a map.
Two optional integrations are left deliberately unset, and both are yours to supply if you want them:
- OpenAIP — adds an aeronautical overlay (airspaces, navaids, reporting points). Without a key that overlay simply returns "not configured" and the rest of the map is unaffected.
- AeroDataBox — enriches flight lookup by flight number.
Add either from Settings in the web UI. No third-party key is baked into this image.
Using your own domain
On first boot the instance sets its own address, ORIGIN, to http://<vm-ip>. AirTrail uses that value to decide which form submissions to trust, so if you reach the VM by a DNS name rather than its IP address you must update it. Point a hostname at the VM, then set ORIGIN in /opt/airtrail/.env to that URL — for example https://airtrail.example.com if you terminate TLS on a reverse proxy in front — and restart the service:
sudo systemctl restart airtrail
The same applies if the VM's public IP changes, for example after a deallocate and start.
Managing the service
# view recent application logs
sudo docker compose -f /opt/airtrail/docker-compose.yml logs --tail 50 airtrail
# restart the whole stack
sudo systemctl restart airtrail
The upstream release notes and complete corresponding source for the version shipped here are recorded on the VM at /usr/share/doc/airtrail/, alongside the GPL-3.0 licence text.
Support
cloudimg provides 24/7 support with a guaranteed 24 hour response SLA. Contact support@cloudimg.co.uk for assistance with this image.