AirTrail 3.14.0
Overview
AirTrail is an open source (GPL-3.0) personal flight tracker. Log every flight you have taken or booked, with airline, aircraft, flight number, seat, class and reason. See your flights as arcs on a world map, and get statistics on distance, airports, routes, airlines, aircraft and countries. You can import your history from Flighty, App in the Air, JetLog, byAir and other trackers, add users for your family or team, and script your log through AirTrail's REST API with scoped API keys.
This guide covers launching and verifying AirTrail 3.14.0 on Ubuntu 24.04 from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud self-hosted personal apps listing on AWS Marketplace. You select it as the software version when you subscribe and launch.
What you get:
- AirTrail 3.14.0 from the official upstream container image
johly/airtrail:v3.14.0, unmodified, and PostgreSQL 16 frompostgres:16-alpine, both pinned by digest - Docker CE (official Docker apt repository) running the stack under the systemd unit
airtrail.service - AirTrail's airport, airline and aircraft reference data, loaded into the database on first boot from the image itself
- nginx as the only network-facing service: HTTPS on port 443 with a self-signed certificate created on first boot, port 80 redirecting to HTTPS
- A per-instance owner password and database password, generated on first boot into root-only files
airtrail-set-hostname, a helper that serves AirTrail under your own DNS name and re-issues the certificate for it, andairtrail-admin, a root-only wrapper around upstream's admin command line
Default user: ubuntu. Architecture: x86_64. Root volume: 30 GiB gp3.
Security posture
- No unclaimed instance. Upstream AirTrail lets the first visitor to a new instance create the owner account, and its example configuration uses a fixed database password. Neither applies here. First boot creates the owner
adminwith a 28-character password, and a 40-character database password, both generated on each instance, so no two instances share one. The set-up page then refuses a second owner (400). The only account isadmin. - No open sign-up. AirTrail has no self-registration; you add users yourself in Settings > Users. OAuth sign-in is not configured.
- Fails closed. AirTrail does not start unless the secrets exist, are root-only and long enough, demo mode and rate-limit bypasses are off, and the origin names a real HTTPS host. nginx stays closed until first boot has confirmed that the owner password signs in.
- No secret in the container configuration. The app reads its database address and password from root-only files when it starts, so
docker inspect, the compose file and the logs never show one. - No example data. A new instance has exactly one account and no flights.
- Only nginx faces the network. AirTrail listens on
127.0.0.1:3000; PostgreSQL sits on an internal Docker network with no host port and no route off the instance. nginx answers only for the configured hostname (any otherHostheader gets its connection closed), uses TLS 1.2 or 1.3, refuses cross-originPOSTrequests and WebSocket upgrades (403), sends no CORS grants, rate-limits sign-in attempts, forces the session cookie toSecure,HttpOnlyandSameSite=Lax, and replaces any client-suppliedX-Forwarded-For,X-Real-IPorForwardedheader with the real peer address. - One reading of every path. nginx refuses (
400) any request whose path contains a backslash, an encoded slash, backslash or dot (%2F,%5C,%2E) or a.or..segment, so nginx and AirTrail always see the same path. Only AirTrail's exact API, MCP and OAuth token routes, which authenticate with a bearer key and never with the browser session, are passed through without the cross-origin guard. - The hostname comes from configuration, never from a request. First boot reads the instance's public address from EC2 instance metadata (IMDSv2). nginx hands AirTrail that fixed name, so links, redirects and cookies cannot be steered by a request.
- The containers cannot reach your private network or instance metadata. A firewall chain on the app's Docker bridge rejects every new connection from the containers to link-local (including the metadata service), carrier-grade NAT, RFC 1918 (your VPC), loopback and reserved ranges, and to the instance itself. Only DNS to the instance's own resolver is allowed. The rules are in place before Docker starts any container and are reloaded in one atomic step, and nginx, Docker and the AirTrail unit are also blocked from metadata by systemd
IPAddressDeny. This was proved at build time from inside the app container. - Least-privilege containers. AirTrail runs as an unprivileged user (uid 1000) with no effective capabilities on a read-only filesystem; the container keeps only the four capabilities upstream's start-up script needs to drop to that user. The database container drops every capability except the few PostgreSQL's entrypoint needs. Both run with
no-new-privileges, a memory limit and core dumps off. - No scheduled firmware-metadata refresh. The
fwupdfirmware updater stays installed, but its LVFS metadata refresh (fwupd-refresh.timerand.service) is masked, so an LVFS outage never marks your instance unhealthy at boot. If you use fwupd, runsudo fwupdmgr refreshyourself. - No core dumps. nginx, the AirTrail units and the containers run with core dumps disabled, so process memory holding your credentials is never written to disk.
- IMDSv2 required. The AMI is registered with
ImdsSupport v2.0, so instances refuse token-less (IMDSv1) metadata requests. - Per-instance identity. SSH host keys, the machine ID, the TLS certificate and Docker's engine ID are generated fresh on every instance.
Prerequisites
- An AWS account and permission to launch EC2 instances
- An EC2 key pair in the Region you launch in
- Internet access from your users' browsers for the map background, because the browser loads map tiles directly
Recommended instance type: m5.large. AirTrail and PostgreSQL run comfortably on 2 vCPUs and 8 GiB of memory for a household or a small team, with years of flights.
Connecting to your instance
AirTrail 3.14.0 on Ubuntu 24.04 runs on Ubuntu 24.04 LTS. Connect over SSH as the ubuntu user.
Launch the instance
Option A: AWS Marketplace console
- Open the SecureAnyCloud Hardened Actual Budget & Self-Hosted Personal Apps AMIs listing on AWS Marketplace. Choose View purchase options, then Subscribe. One subscription covers every version in the listing.
- When the subscription is active, choose Launch your software.
- Choose One-click launch from AWS Marketplace, or Launch from EC2 Console for the full launch wizard. In Version, choose AirTrail 3.14.0 on Ubuntu 24.04, then choose your Region.
- Pick an instance type and your key pair.
m5.largeis recommended. The software charge is $0.04 per vCPU per hour. - Configure the security group. The listing's default group opens SSH (22), HTTP (80) and HTTPS (443). Restrict SSH to your own IP address, and restrict 80 and 443 to the networks you work from.
- Choose your VPC and subnet, leave the metadata defaults (the image already requires IMDSv2), then choose Launch.
Option B: AWS CLI
Run these commands on your own workstation. They create a security group that allows SSH from your management address and HTTPS from your own network, then launch the instance. Replace the AMI ID with the one shown for your Region on the Marketplace launch page. Replace the VPC, subnet, key pair and CIDR ranges with your own:
SG=$(aws ec2 create-security-group --group-name airtrail \
--description "AirTrail" --vpc-id vpc-0123456789abcdef0 \
--query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr <your-mgmt-cidr>
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 443 --cidr 203.0.113.0/24
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 80 --cidr 203.0.113.0/24
aws ec2 run-instances \
--image-id ami-0123456789abcdef0 \
--instance-type m5.large \
--key-name my-key-pair \
--security-group-ids "$SG" \
--subnet-id subnet-0123456789abcdef0 \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=airtrail}]'You do not need --metadata-options, because the AMI already requires IMDSv2. To keep the address across a stop and start, associate an Elastic IP.
Connect and find your login
Connect as ubuntu with the key pair you chose:
ssh -i ~/.ssh/my-key-pair.pem ubuntu@<public-ip>First boot takes a few minutes. It does the following, in order:
- Sets the hostname that AirTrail is served as. This is the instance's public IP address, so the session cookie works without a DNS name.
- Creates the TLS certificate for that hostname and generates the secrets.
- Creates the database, loads the reference data, and creates the
adminowner account. - Confirms that the password signs in, and writes
/root/airtrail-credentials.txt(mode 0600, root). - Opens nginx.
The following output shows the entries in the credentials file, then the names of every key it holds. The password itself is not shown:
sudo grep -E '^AIRTRAIL_(URL|USERNAME)=' /root/airtrail-credentials.txt
sudo grep -oE '^[A-Z_]+=' /root/airtrail-credentials.txt | tr -d '='
sudo stat -c '%a %U:%G' /root/airtrail-credentials.txtAIRTRAIL_URL=https://52.91.248.82/ AIRTRAIL_USERNAME=admin AIRTRAIL_URL AIRTRAIL_USERNAME AIRTRAIL_PASSWORD 600 root:rootValue What it is for
AIRTRAIL_URL The address of this instance's AirTrail
AIRTRAIL_USERNAME admin, the owner and only account
AIRTRAIL_PASSWORD The owner password, generated on this instance
To read the password, run sudo cat /root/airtrail-credentials.txt in your own session. Store the password in your password manager.
Confirm AirTrail is running
Confirm that Docker, the firewall unit, AirTrail and nginx are active. Only sshd and nginx listen beyond loopback. AirTrail listens on loopback, and PostgreSQL has no host port at all:
systemctl is-active docker.service sac-docker-imds-deny.service airtrail.service nginx.service
sudo docker ps --format '{{.Names}} {{.Image}} {{.Ports}}' | sed -E 's/@sha256:[0-9a-f]+//' | sort
sudo ss -Htlnp | awk '{print $4, $6}' | sed -E 's/,pid=[0-9]+,fd=[0-9]+//g' | sort -u
H=$(sudo sed -n 's/^set \$app_host "\(.*\)";$/\1/p' /etc/nginx/airtrail-host.conf)
curl -sk --resolve "$H:443:127.0.0.1" "https://$H/healthz"
curl -sk --resolve "$H:443:127.0.0.1" "https://$H/api/ping"; echoactive active active active airtrail johly/airtrail:v3.14.0 127.0.0.1:3000->3000/tcp airtrail-db postgres:16-alpine 5432/tcp 0.0.0.0:22 users:(("sshd"),("systemd")) 0.0.0.0:443 users:(("nginx"),("nginx"),("nginx")) 0.0.0.0:80 users:(("nginx"),("nginx"),("nginx")) 127.0.0.1:3000 users:(("docker-proxy")) 127.0.0.53%lo:53 users:(("systemd-resolve")) 127.0.0.54:53 users:(("systemd-resolve")) [::]:22 users:(("sshd"),("systemd")) [::]:443 users:(("nginx"),("nginx"),("nginx")) [::]:80 users:(("nginx"),("nginx"),("nginx")) ok pongnginx answers /healthz itself and reveals nothing, so it is safe to use as a load balancer health check. AirTrail answers /api/ping through nginx.
Prove the login and the web defaults
These commands check the following behavior:
- A signed-out visit is sent to the sign-in page.
- The REST API refuses a request without a key.
- A second owner set-up is refused.
- A cross-origin
POSTis refused. - A path that hides
..behind an encoded slash is refused. - An unknown
Hostheader gets no answer. - Plain HTTP redirects to HTTPS.
The last command signs in as admin with this instance's password, and once with a wrong password, and reports the result:
H=$(sudo sed -n 's/^set \$app_host "\(.*\)";$/\1/p' /etc/nginx/airtrail-host.conf) R=(--resolve "$H:443:127.0.0.1") curl -sk "${R[@]}" "https://$H/__data.json"; echo curl -sk "${R[@]}" -o /dev/null -w 'API v1 without a key -> %{http_code}\n' "https://$H/api/v1/me" curl -sk "${R[@]}" -o /dev/null -w 'second owner set-up -> %{http_code}\n' -H "Origin: https://$H" \
-d 'username=intruder&password=Intruder-Password-1&displayName=Intruder' "https://$H/api/users/setup" curl -sk "${R[@]}" -o /dev/null -w 'cross-origin POST -> %{http_code}\n' -H 'Origin: https://evil.example' -d 'username=x&password=x' "https://$H/api/users/login" curl -sk "${R[@]}" -o /dev/null -w 'encoded path trick -> %{http_code}\n' --path-as-is "https://$H/api/v1/..%2Fvisited-countries" curl -sk -o /dev/null -w 'unknown Host -> %{http_code}\n' -H 'Host: evil.example' https://127.0.0.1/ || true curl -s -o /dev/null -w 'http -> %{http_code}\n' -H "Host: $H" http://127.0.0.1/
sudo airtrail-roundtrip.sh{"type":"redirect","location":"./login"} API v1 without a key -> 401 second owner set-up -> 400 cross-origin POST -> 403 encoded path trick -> 400 unknown Host -> 000 http -> 301 OK round-trip https://52.91.248.82/: healthz 200; http 301; unknown Host refused; ping pong; anonymous page data to login and API 401; ambiguous paths 400 (5 forms), OAuth metadata 200, API v1 without a token 401; cross-origin POST 403; owner seat taken; wrong password refused with no session; per-VM owner login session cookie Secure HttpOnly SameSite=Lax; API 503 and owner page data with the sessionSign in to AirTrail
Browse to AIRTRAIL_URL (https://<public-ip>/). The certificate is self-signed, so your browser warns once. Accept it for this host, or install your own certificate (Step 8). Sign in as admin with AIRTRAIL_PASSWORD:
A new instance has no flights. Add flights with the + button on the map, or import a file from another tracker with Import flights in Settings. You can also use the API, as described in the next step.
Add flights with an API key
AirTrail's REST API (/api/v1) authenticates with API keys that carry only the scopes you give them. You can also create and revoke keys in Settings > API Keys.
The following commands sign in once and create a key. The key can read your profile and the reference data, read and add your own flights, and read statistics. The key is saved in ~/airtrail-api-key (mode 0600). The password goes to curl on standard input, so it never appears on a command line:
H=$(sudo sed -n 's/^set \$app_host "\(.*\)";$/\1/p' /etc/nginx/airtrail-host.conf) R=(--resolve "$H:443:127.0.0.1");
J=$(mktemp); chmod 600 "$J" at() { curl -sk "${R[@]}" -b "$J" -c "$J" -H "Origin: https://$H" "$@"; }
sudo grep '^AIRTRAIL_PASSWORD=' /root/airtrail-credentials.txt | cut -d= -f2- | tr -d '\n' | at -o /dev/null -w 'sign in -> %{http_code}\n' --data-urlencode 'username=admin' --data-urlencode 'password@-' "https://$H/api/users/login" ( umask 077 at -H 'Content-Type: application/json' \
--data-binary '"[{\"name\":1,\"scopes\":2},\"flight-log\",[3,4,5,6,7],\"profile.read\",\"reference_data.read\",\"flight.read.own\",\"flight.create.own\",\"stats.read\"]"' \
"https://$H/api/trpc/user.createApiKey" | grep -o 'key:\\"[a-z0-9]*' | cut -d'"' -f2 > ~/airtrail-api-key ) rm -f "$J" echo "API key saved to ~/airtrail-api-key: $(tr -d '\n' < ~/airtrail-api-key | wc -c) characters, mode $(stat -c %a ~/airtrail-api-key)" api() { curl -sk "${R[@]}" -H "Authorization: Bearer $(cat ~/airtrail-api-key)" "$@"; } api "https://$H/api/v1/me" | jq -c '.data | {username, displayName}' api "https://$H/api/v1/airports?query=LHR" | jq -r '.data[] | select(.iata == "LHR") | "\(.iata) \(.name)"' api "https://$H/api/v1/airlines?query=BA" | jq -r '.data[] | select(.iata == "BA") | "\(.iata) \(.name)"' api "https://$H/api/v1/aircraft?query=A35K" | jq -r '.data[] | select(.icao == "A35K") | "\(.icao) \(.name)"'sign in -> 303 API key saved to ~/airtrail-api-key: 32 characters, mode 600 {"username":"admin","displayName":"AirTrail Admin"} LHR London Heathrow Airport BA British Airways A35K Airbus A350-1000Add four flights with that key. Each command looks up the airport, airline and aircraft by its code. Then read the statistics back:
H=$(sudo sed -n 's/^set \$app_host "\(.*\)";$/\1/p' /etc/nginx/airtrail-host.conf); R=(--resolve "$H:443:127.0.0.1") api() { curl -sk "${R[@]}" -H "Authorization: Bearer $(cat ~/airtrail-api-key)" "$@"; } airport() { api "https://$H/api/v1/airports?query=$1" | jq -r --arg c "$1" '[.data[] | select(.iata == $c)][0].id'; } airline() { api "https://$H/api/v1/airlines?query=$1" | jq -r --arg c "$1" '[.data[] | select(.iata == $c)][0].id'; } aircraft() { api "https://$H/api/v1/aircraft?query=$1" | jq -r --arg c "$1" '[.data[] | select(.icao == $c)][0].id'; }
ME=$(api "https://$H/api/v1/me" | jq -r .data.id) for f in '2026-03-14|LHR|JFK|BA|BA117|A35K|window|business' \
'2026-03-21|JFK|LHR|BA|BA112|A35K|aisle|business' \
'2026-05-02|LHR|CDG|AF|AF1081|A320|window|leisure' \
'2026-08-09|CDG|NRT|AF|AF276|B77W|window|leisure'; do IFS='|' read -r d from to al no ac seat reason <<<"$f" jq -nc --arg d "$d" --argjson from "$(airport "$from")" --argjson to "$(airport "$to")" \
--argjson al "$(airline "$al")" --argjson ac "$(aircraft "$ac")" --arg no "$no" \
--arg me "$ME" --arg seat "$seat" --arg reason "$reason" \
'{date:$d, fromId:$from, toId:$to, airlineId:$al, aircraftId:$ac, flightNumber:$no, passengers:[{userId:$me, seat:$seat, seatClass:"economy", flightReason:$reason}]}' | api -o /dev/null -w "$no $from-$to -> %{http_code}\n" -H 'Content-Type: application/json' --data-binary @- "https://$H/api/v1/flights" done api "https://$H/api/v1/stats" | jq -r '.data | "flights \(.flights), \(.distanceKm) km, \(.airports) airports", "top airline \(.topAirline.name), top airport \(.topAirport.iata), top aircraft \(.topAircraft.name)"'BA117 LHR-JFK -> 201 BA112 JFK-LHR -> 201 AF1081 LHR-CDG -> 201 AF276 CDG-NRT -> 201 flights 4, 21137 km, 4 airports top airline British Airways, top airport LHR, top aircraft Airbus A350-1000The map shows each flight as an arc between its airports:
Statistics breaks your flights down by year, month and weekday, airports, routes, destinations, airlines, aircraft types, seat class, seat position and reason:
All Flights lists your flights, newest first, with route, date, flight number, airline and aircraft:
When you no longer need the key, delete ~/airtrail-api-key and revoke the key in Settings > API Keys.
Your browser loads the map background from the internet. An instance with no outbound access still works, but it shows no map tiles.
Check the security controls
These commands confirm the following:
- First boot made the certificate for this instance, and it is a plain server certificate.
- The firewall chain on the app's bridge rejects private destinations.
- The units are denied access to instance metadata.
- The containers run with dropped capabilities, and the app process has no effective capabilities.
- The database network is internal.
- The secrets are closed to other users.
- The app container cannot reach the metadata service.
H=$(sudo sed -n 's/^set \$app_host "\(.*\)";$/\1/p' /etc/nginx/airtrail-host.conf)
openssl s_client -connect 127.0.0.1:443 -servername "$H" </dev/null 2>/dev/null | openssl x509 -noout -subject -enddate -ext basicConstraints,extendedKeyUsage
sudo iptables -S DOCKER-USER
sudo iptables -S SAC-AT-EGRESS | grep -oE '\-d [0-9./]+ -j REJECT' | awk '{print $2}' | paste -sd' ' for u in nginx docker airtrail; do echo "$u.service IPAddressDeny=$(systemctl show -p IPAddressDeny --value $u.service)"; done
sudo docker inspect --format '{{.Name}} cap_drop={{.HostConfig.CapDrop}} cap_add={{.HostConfig.CapAdd}} read_only={{.HostConfig.ReadonlyRootfs}}' airtrail airtrail-db
P=$(sudo docker inspect --format '{{.State.Pid}}' airtrail);
sudo grep -E '^(Uid|CapEff):' /proc/$P/status
sudo docker network inspect airtrail-db --format 'database network {{.Name}}: internal={{.Internal}}'
sudo stat -c '%a %U:%G %n' /opt/airtrail/secrets /opt/airtrail/secrets/owner_password /root/airtrail-credentials.txt
sudo docker exec airtrail node -e 'fetch("http://169.254.169.254/latest/meta-data/",{signal:AbortSignal.timeout(5000)}).then(()=>console.log("metadata reachable")).catch(e=>console.log("metadata from the app container: "+(e.cause&&e.cause.code||e.name)))'subject=CN = 52.91.248.82, O = sac notAfter=Feb 12 18:56:47 2029 GMT X509v3 Basic Constraints: critical CA:FALSE X509v3 Extended Key Usage: TLS Web Server Authentication -N DOCKER-USER -A DOCKER-USER -d 168.63.129.16/32 -j DROP -A DOCKER-USER -d 169.254.169.254/32 -j DROP -A DOCKER-USER -i at-web0 -j SAC-AT-EGRESS 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4 nginx.service IPAddressDeny=169.254.169.254/32 168.63.129.16/32 docker.service IPAddressDeny=169.254.169.254/32 168.63.129.16/32 airtrail.service IPAddressDeny=169.254.169.254/32 168.63.129.16/32 /airtrail cap_drop=[ALL] cap_add=[CAP_CHOWN CAP_DAC_READ_SEARCH CAP_SETGID CAP_SETUID] read_only=true /airtrail-db cap_drop=[ALL] cap_add=[CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_SETGID CAP_SETUID] read_only=false Uid: 1000 1000 1000 1000 CapEff: 0000000000000000 database network airtrail-db: internal=true 700 root:root /opt/airtrail/secrets 600 root:root /opt/airtrail/secrets/owner_password 600 root:root /root/airtrail-credentials.txt metadata from the app container: TimeoutErrorThe AirTrail container keeps CHOWN, DAC_READ_SEARCH, SETGID and SETUID only so that upstream's start-up script can prepare the upload directory and drop to uid 1000. The AirTrail process itself runs with no effective capabilities, as CapEff shows. The database container keeps only the few capabilities that PostgreSQL's entrypoint needs to drop to the postgres user.
Serve AirTrail under your own DNS name
Point a DNS record at the instance. An Elastic IP keeps the address stable. Then tell AirTrail the name.
The helper re-issues the self-signed certificate for the name and updates nginx's accepted name, AirTrail's origin and the cross-origin guard. It records the name in /etc/sac/airtrail-hostname, which persists across reboots, restarts AirTrail and updates AIRTRAIL_URL:
sudo airtrail-set-hostname flights.example.comairtrail-set-hostname: AirTrail is served at https://flights.example.com/Until you set a name, the instance follows its own public IP address. If the instance comes back from a stop and start with a new address, boot updates the certificate and settings.
To use a certificate from your own CA or from Let's Encrypt instead, follow these steps:
- Set the name with
airtrail-set-hostnamefirst, so the automatic refresh leaves your certificate alone. - Replace
/etc/ssl/airtrail/airtrail.crtand/etc/ssl/airtrail/airtrail.key. Keep the key at mode 0600, owned by root. - Run
sudo systemctl reload nginx.
Add users and change settings
AirTrail has no sign-up page, so you create accounts yourself. Signed in as admin, open Settings > Users and choose Add user. Set a username, display name and password.
Each user signs in at AIRTRAIL_URL and sees only their own flights, plus flights they are a passenger on. Settings > Roles controls what each role may do.
In their own Settings, users can change their name and password, import and export their flights, manage their API keys, and create share links that give a read-only view of their flights.
Manage accounts from the shell
airtrail-admin runs upstream's admin command line inside the container with the instance's settings. List the accounts and the version with these commands:
sudo airtrail-admin list-users
sudo airtrail-admin versionUsers (1): Username Display Name Role OAuth ID -------- -------------- ----- ----- --------------- admin AirTrail Admin Owner no 8dt57j54elnx4ny AirTrail v3.14.0Change the owner password in Settings. If you lose it, reset it from an interactive SSH session. The command asks for the username and the new password:
sudo airtrail-admin reset-passwordThen update /root/airtrail-credentials.txt with sudo nano, or delete the file, so it does not hold a stale password. The set-role, grant-admin and revoke-admin commands change a user's role the same way.
Maintenance
- Logs. View app logs with
sudo docker logs airtrail. HTTPS access logs are in/var/log/nginx/. First boot logs are available throughsudo journalctl -u airtrail-firstboot.service -u airtrail-postboot.service. - Restart. Run
sudo systemctl restart airtrailorsudo systemctl restart nginx. - Backups. The database is in
/var/lib/airtrail/pgdataand uploaded files are in/var/lib/airtrail/uploads. Take EBS snapshots of the root volume, or dump the database withsudo docker exec airtrail-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > airtrail.sqland copy the uploads directory. Each user can also export their flights from Settings. - Operating system updates. Ubuntu's unattended security upgrades stay on, so nginx, Docker and the rest of the OS keep receiving patches. Apply all other updates with
sudo apt update && sudo apt upgradein a maintenance window. - Upgrading AirTrail. The images are pinned by digest, and first boot never pulls. To upgrade deliberately, read the release notes, back up, and pull the new release by digest. Then change the AirTrail
image:line in/opt/airtrail/docker-compose.ymland runsudo systemctl restart airtrail. Keep thecap_drop,cap_add,security_opt,read_only,ulimits, loopbackportsand network settings, including theat-web0bridge name that the firewall chain is keyed on. Never publish a port on all interfaces, and never use host networking, which would bypass both nginx and the firewall chain.
Troubleshooting
The sign-in page says the password is wrong. Use admin and AIRTRAIL_PASSWORD from /root/airtrail-credentials.txt exactly, over https://. After several failed attempts, AirTrail pauses sign-in for that username for a while, and nginx answers 429 to quick repeated attempts. Wait and try again.
nginx is not running. On a new instance, nginx waits by design until first boot has confirmed the credentials. Check with sudo journalctl -u airtrail-postboot.service --no-pager | tail -20 and sudo systemctl status airtrail.
AirTrail does not start. Run sudo journalctl -u airtrail --no-pager | tail -20 to see the reason from the start-up check. Typical causes are a secret file whose mode was changed, or a settings file edited to turn on demo mode or switch off rate limits. Restore the setting and run sudo systemctl restart airtrail.
You are signed in, but the page shows an error after you change the address. AirTrail's origin check and cookies are tied to the configured name. Browse to the exact AIRTRAIL_URL, or set the name you use with airtrail-set-hostname.
A request answers 400 Bad Request. The request path contains a backslash, an encoded slash, backslash or dot, or a . or .. segment, which nginx refuses by design. Use the plain path. Encoded characters in the query string are fine.
The API answers 401 or 403. Send the key as Authorization: Bearer <key>. In Settings > API Keys, check that the key is not revoked and carries the scope the endpoint needs, for example flight.create.own to add flights.
Support
SecureAnyCloud provides 24/7 support for this image by email and live chat. Support covers launching and connecting, users and roles, the API, TLS and hostnames, and upgrades.
AirTrail is developed by Johan Ohly and contributors and is released under the GNU General Public License v3.0. The license text and a pointer to the corresponding source ship on the instance in /usr/share/doc/airtrail/. PostgreSQL is released under the PostgreSQL License. Ubuntu is a registered trademark of Canonical Ltd. Docker is a trademark of Docker, Inc. All other product and company names are trademarks or registered trademarks of their respective holders. SecureAnyCloud is not affiliated with or endorsed by them.
Deploy on AWS
Launch AirTrail 3.14.0 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.
View on Marketplace
Need Help?
Our support team is available 24/7.
sales@secureanycloud.com