AirTrail 3.14.0 on AWS User Guide
Overview
This guide covers launching and verifying AirTrail 3.14.0 on Ubuntu 24.04 from cloudimg on Amazon EC2. It is one version of the cloudimg self-hosted personal apps listing on AWS Marketplace; you pick it as the software version when you subscribe and launch.
AirTrail is the 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 them as arcs on a world map; and get statistics on distance, airports, routes, airlines, aircraft and countries. Import your history from Flighty, App in the Air, JetLog, byAir and other trackers, add more users for your family or team, and script your log through AirTrail's REST API with scoped API keys.
What is on the image:
- 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 generated on each instance, so no two instances share one, and 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 will 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 proved that the owner password really 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; runsudo fwupdmgr refreshyourself if you use it. - 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
- Outbound internet access from the instance's users' browsers for the map background (the browser loads map tiles directly)
Recommended instance type: m5.large. AirTrail and PostgreSQL are comfortable on 2 vCPUs and 8 GiB of memory for a household or a small team, with years of flights.
Connecting to your instance
| Version | Operating system | SSH user |
|---|---|---|
| AirTrail 3.14.0 on Ubuntu 24.04 | Ubuntu 24.04 LTS | ubuntu |
Step 1: Launch the instance
Option A: AWS Marketplace console
- Open the cloudimg Hardened Actual Budget & Self-Hosted Personal Apps AMIs listing on AWS Marketplace and choose View purchase options, then Subscribe. One subscription covers every version in the listing.
- Once 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 (
m5.largerecommended; the software charge is $0.04 per vCPU per hour) and your key pair. - Security group: the listing's default group opens SSH (22), HTTP (80) and HTTPS (443). Restrict SSH to your own IP address, and 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 this on your own workstation. It creates a security group that allows SSH from your management address and HTTPS from your own network, then launches the instance. Replace the AMI ID with the one shown for your Region on the Marketplace launch page, and 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: the AMI already requires IMDSv2. If you want the address to survive a stop and start, associate an Elastic IP.
Step 2: 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 sets the hostname AirTrail is served as (the instance's public IP address, so the session cookie works without a DNS name), creates the TLS certificate for it, generates the secrets, creates the database and loads the reference data, creates the admin owner account, proves the password signs in, and writes /root/airtrail-credentials.txt (mode 0600, root). Only then does it open nginx. These are the entries in that file, then the names of every key it holds (the password itself is not shown here):
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.txt
AIRTRAIL_URL=https://52.91.248.82/
AIRTRAIL_USERNAME=admin
AIRTRAIL_URL
AIRTRAIL_USERNAME
AIRTRAIL_PASSWORD
600 root:root
| Value | 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 and store it in your password manager.
Step 3: Confirm AirTrail is running
Docker, the firewall unit, AirTrail and nginx are active. Only sshd and nginx listen beyond loopback; AirTrail is on loopback and PostgreSQL has no host port at all:
systemctl is-active docker.service cloudimg-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"; echo
active
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
pong
/healthz is answered by nginx itself and reveals nothing, so it is safe to use as a load balancer health check. /api/ping is answered by AirTrail through nginx.
Step 4: Prove the login and the web defaults
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 POST is refused, a path that hides .. behind an encoded slash is refused, an unknown Host header gets no answer and plain HTTP redirects to HTTPS. The last command signs in as admin with this instance's password (and once with a wrong one) 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 session
Step 5: Sign 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 them with the + button on the map, import a file from another tracker with Import flights in Settings, or use the API as in the next step.
Step 6: Add flights with an API key
AirTrail's REST API (/api/v1) authenticates with API keys that carry only the scopes you give them. This signs in once, creates a key that can read your profile and the reference data, read and add your own flights and read statistics, and keeps it in ~/airtrail-api-key (mode 0600). The password goes to curl on standard input, so it never appears on a command line. You can also create and revoke keys in Settings > API Keys:
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-1000
Now add four flights with that key, looking up each airport, airline and aircraft by its code, and 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-1000
The 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 them, newest first, with route, date, flight number, airline and aircraft:

Delete ~/airtrail-api-key and revoke the key in Settings > API Keys when you no longer need it. The map background is loaded from the internet by your browser, so an instance with no outbound access still works but shows no map tiles.
Step 7: Check the security controls
The certificate was made for this instance on first boot and is a plain server certificate, the firewall chain on the app's bridge rejects private destinations, the units are denied metadata, the containers run with dropped capabilities, the app process has no effective capabilities, the database network is internal, the secrets are closed to other users, and 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 CLOUDIMG-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 = cloudimg
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 CLOUDIMG-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: TimeoutError
The 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 the few capabilities PostgreSQL's entrypoint needs to drop to the postgres user.
Step 8: 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 it, updates nginx's accepted name, AirTrail's origin and the cross-origin guard, records the name in /etc/cloudimg/airtrail-hostname (kept across reboots), restarts AirTrail and updates AIRTRAIL_URL:
sudo airtrail-set-hostname flights.example.com
airtrail-set-hostname: AirTrail is served at https://flights.example.com/
Until you set a name, the instance follows its own public IP address: if it comes back from a stop and start with a new address, the certificate and settings are updated at boot.
To use a certificate from your own CA or from Let's Encrypt instead, replace /etc/ssl/airtrail/airtrail.crt and /etc/ssl/airtrail/airtrail.key (keep the key mode 0600, owner root), set the name with airtrail-set-hostname first so the automatic refresh leaves your certificate alone, and run sudo systemctl reload nginx.
Step 9: Add users and change settings
There is no sign-up page, so you create accounts yourself. Signed in as admin, open Settings > Users and choose Add user, then 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.
Each user's Settings also changes their name and password, imports and exports their flights, manages their API keys, and creates share links for a read-only view of their flights.
Step 10: 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:
sudo airtrail-admin list-users
sudo airtrail-admin version
Users (1):
Username Display Name Role OAuth ID
-------- -------------- ----- ----- ---------------
admin AirTrail Admin Owner no 8dt57j54elnx4ny
AirTrail v3.14.0
Change 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-password
Then update /root/airtrail-credentials.txt with sudo nano (or delete that file) so it does not hold a stale password. set-role, grant-admin and revoke-admin change a user's role the same way.
Maintenance
- Logs:
sudo docker logs airtrail(the app),/var/log/nginx/for HTTPS access,sudo journalctl -u airtrail-firstboot.service -u airtrail-postboot.servicefor first boot. - Restart:
sudo systemctl restart airtrail, orsudo systemctl restart nginx. - Backups: the database is in
/var/lib/airtrail/pgdataand uploaded files 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 the rest 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, pull the new release by digest, 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 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 it waits until first boot has proved the credentials, by design. Check with sudo journalctl -u airtrail-postboot.service --no-pager | tail -20 and sudo systemctl status airtrail.
AirTrail does not start. sudo journalctl -u airtrail --no-pager | tail -20 shows the start-up check's reason, for example a secret file whose mode was changed or a settings file edited to turn on demo mode or switch off rate limits. Put the setting back and run sudo systemctl restart airtrail.
Signed in, but the page shows an error after changing 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. Its 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>, and check in Settings > API Keys that it is not revoked and carries the scope the endpoint needs (for example flight.create.own to add flights).
Support
cloudimg provides 24/7 support for this image by email and live chat: 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 licence text and a pointer to the corresponding source ship on the instance in /usr/share/doc/airtrail/. PostgreSQL is released under the PostgreSQL Licence. 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. cloudimg is not affiliated with or endorsed by them.