π§ TripWise¶

TripWise is a self-hosted web application for planning trips. A trip is built as an itinerary of stages: places, dates, notes, and points of interest, and browsed in a clean web UI.
Introduction¶
The goal of TripWise is to keep a whole trip plan in one place instead of scattered notes, bookmarks, and spreadsheets. Each trip holds a single itinerary of items (transport, lodging, food, activities) that feeds a to-do list, an interactive map, a day-by-hour planner, a budget and can be shared with other users or exported as a PDF + attachments archive.
This page covers installing and running the application as a self-contained web service.
Info
TripWise is open-source and lives in this repository under src/
(Rust backend + gmaps and osrm sidecars + React/TypeScript frontend), with the Podman
packaging in build/, quadlet/ and nginx/.
Features¶
- π§³ One itinerary, four views: transport, lodging, food and activities feed the to-do list, the map, the planner and the budget β each item marked nothing to book, to book, booked or paid, with a toolbar count that filters what is left to book
- ποΈ Day-by-hour planner board: items as duration blocks, a dashed idea tray per day, drag-and-drop scheduling on a 15-minute snap β anything with no stated length taking a 15-minute block β and ideas promoted to the itinerary in one click; a hotel stay dragged back onto one of its own days, check-in to check-out, leaves a stopover at that hour β kept on those days, as many as the day needs β so a day reads museum β hotel β dinner β hotel and shows when, and for how long, you are back at it; and every morning of a stay opens with the hotel you woke up in, a morning link set to leave 30 minutes before the day's first entry β yours to move and annotate, never to delete β from which the day's first walk is timed
- π Interactive keyless map β OpenFreeMap vector street map with English-first labels, Esri satellite view β with category-tinted pins that carry the item's own icon β train, plane, Booking, Airbnb, activityβ¦ (planner ideas included), transport routes that thicken and name themselves under the cursor (a click frames the leg and selects it), and Google Maps hand-off at the zoom you pick in your profile (19 by default, previewed there on a real station; a flight's or a train's airports and stations keep Google's own) β and a day-grouped list beside the map
- πΆ Walking layer chaining each day's stops on foot: routed server-side and cached, and coloured green / orange / red by whether the walk fits the time the itinerary leaves for it β hovering names its distance and duration, clicking frames the leg and both its ends, and the badge totals only the paths in view
- π Live updates: a change made in one browser tab, on another device, or by someone you share the trip with shows up on its own β the map, the itinerary, the planner and the budget repaint without a reload
- π‘ Trip focus: a top-bar calendar picks one day of the trip β the map (its list, its pins, its camera), the itinerary and the planner then show that day alone, and βΉ βΊ in the trip's header step to the next one β or turns on Live mode, which hides every day already behind you (this morning's and tonight's lodging and undated items stay put); remembered per trip on each device, the whole trip by default
- π Worldwide place search with autocomplete or a pasted Google Maps link (desktop or app share link) pinning exactly that place, pick the spot directly on the map, or use your current position (a phone's GPS) in one tap β places are named in English whenever Google has an English name, even one searched or linked in Japanese
- π° Multi-currency budget with manual exchange rates, a per-category breakdown, a paid / booked / not-booked split telling what is settled from what is reserved but still owed and what you only know the price of, and a cost-per-day bar chart β a hotel counted on each of its nights, trains and planes spread evenly over the whole trip β printed in the PDF export too
- π Tickets and receipts attached to items, exported in one click as a styled PDF + attachments ZIP β a cover opening on the trip's photo edge to edge with its name and dates over it, then its key figures (the total, the share paid, the average day, the trip's size) and the whole budget with sharp vector charts, a clickable day index on page 2, each day opening on a map page of its own β the day's pins, walking paths and their verdict colours drawn over the real streets, the day's walking total and its numbered route under it β then its items on the next pages, as a timetable of time, entry and booking (a ride drawn as a rail from stop to stop with each leg's ticket, a stay as its check-in, nights and check-out, the booking state as a pill, no entry ever cut between two pages) β each route stop a link to its item, each pin lighting its stop on the route (in Acrobat Reader and Firefox), each item page a link back to its day's map β and every page a link back to the index, and bookmarks for the PDF viewer's outline; a train or a flight also keeps its booking's reservation number and code (as a stay does) and what its ticket prints β train or flight number, coach, seat, airline and reference number β and a flight with connections keeps each connecting flight's airport and ticket, shown flight by flight in the item, the PDF and the title, its map route passing through every stop
- β Basic and premium accounts: a basic account's trips hold up to an admin-set amount of attachments (50 MB by default, enforced by the server), shown as a bar in each trip's Settings; premium accounts have no limit β the admin picks each account's plan with a star in the Users page
- β° One email reminder per item ("pick up the tickets the day before"), mailed to everyone with access to the trip at the chosen time β or at the server's daily reminder hour when none is set; a new flight comes with one already set, a check-in reminder 24 hours before departure naming the airline, the flight number, its booking link, the ticket's reference number and the booking's reservation number and code
- π΄ Local-first PWA (Android / desktop install): every trip lives on the device β the app opens instantly with no network or a slow one, edits made offline (trips, items, ideas, rates, attachments) sync in the background (last edit wins), files added offline upload on reconnect, and a trip pinned "Available offline" keeps its attachments β and the PDF/ZIP export (all but its day maps) β working with no network at all, a trip switching itself on from a week before it starts to its last day (switched off by hand, it stays off on that device); sharing, reminders, the walking paths and the profile picture need a connection (offline & sync)
- π€ Per-user trip sharing as viewer or editor β pick the person from the registered accounts (name, email, photo) as you type, or type the address of one just invited; a recipient can leave a trip shared with them from its Settings tab
- π Invite-only accounts with emailed activation links, self-service password reset and optional TOTP 2FA
- π Your own statistics on a Statistics page of their own (the account menu, right under Profile), offline too, every chart at once: trips, days away, items and storage at a glance, then distance and places, where your trips stand, each trip's cost per day and where the money goes (per currency, never mixed), items by category and each trip's attachments against its allowance
- π An admin Statistics page, in the same menu slot (an admin never sees the personal one): accounts, trips, files, storage and sign-ins at a glance, storage by account, attachment types, items by category, trip lengths and a sortable usage table per account β usage only, never a trip's names, places or money
- π An animated travel scene behind the sign-in, activation and email-confirmation pages β a glass globe crossed by flight routes, a plane looping round the world, balloons and landmarks β built on the platform's
@platform/sceneand mounted byLoginBackdrop; the card over it is frosted glass with TripWise's own warm-paper and navy values, opaque under reduced transparency; it takes the login theme, light by day and dark from 18:00 to 08:00 on the device's clock (its night scene), set per server withTRIPWISE_LOGIN_THEME/TRIPWISE_LOGIN_NIGHT; once signed in, the account's own theme takes over - π€ Profile picture, the platform's: uploaded and cropped in the browser (JPEG XL accepted), shown in the top bar and the account menu, kept on the device for an offline start
How it works¶
The backend binary serves both the API and the built React SPA. Trips, itinerary
items, shares and sessions live in PostgreSQL; uploads (avatars, trip thumbnails,
item attachments) are stored as files under the data directory, so the database
stays small. Place searches go through the dedicated gmaps sidecar, the only
component talking to Google, reachable solely on the private container network,
while map tiles (OpenFreeMap vector street map with English-first labels β
OpenStreetMap raster as the silent fallback β and Esri satellite imagery) load
keyless straight in the browser. The map's walking lines follow the same rule as
place search: a dedicated tripwise-osrm sidecar is the only component that
talks to a routing engine β the keyless FOSSGIS pedestrian instance by default,
a self-hosted osrm-routed when OSRM_URL names one β and it keeps every path
it computes in a small database of its own (tripwise_osrm, created on first
start), swept daily. The backend builds each day's chain from its own rows and
asks that sidecar leg by leg, so re-ordering a day only recomputes the pairs
that actually changed and the browser receives a ready-to-draw answer.
π₯ Installation¶
This section describes how to install and run Podman services using systemd Quadlet, enabling containers to restart automatically. It allows the service to run in its own isolated rootless environment with a dedicated Linux user.
π Requirements¶
βοΈ Configuration¶
π Service Setup¶
Info
Please follow the following installation steps:
Define Service Variables
# define the service name
SERVICE_NAME=tripwise
Initialize Service Environment
# built-in bash safety and SERVICE_NAME validation
set -euo pipefail; [ -n "${SERVICE_NAME:-}" ] || { echo "SERVICE_NAME is empty"; exit 1; }
# automatically generate variables
SERVICE_HOME="/media/ssd/podman-users/${SERVICE_NAME}"
SERVICE_DIR="/media/ssd/podman/${SERVICE_NAME}"
SERVICE_USER="${SERVICE_NAME}_svc"
SERVICE_ADMIN="${SERVICE_NAME}_admins"
π Service Isolation¶
Configure Rootless Podman UID/GID Namespace Mappings
# allocate rootless Podman UID/GID mappings if missing
SUBID_SIZE=65536
allocate_subids_if_missing() {
local user="$1"
local start
if ! grep -q "^${user}:" /etc/subuid; then
start="$(awk -F: 'BEGIN { max = 100000 } { end = $2 + $3; if (end > max) max = end } END { print max }' /etc/subuid)"
sudo usermod --add-subuids "${start}-$((start + SUBID_SIZE - 1))" "$user"
fi
if ! grep -q "^${user}:" /etc/subgid; then
start="$(awk -F: 'BEGIN { max = 100000 } { end = $2 + $3; if (end > max) max = end } END { print max }' /etc/subgid)"
sudo usermod --add-subgids "${start}-$((start + SUBID_SIZE - 1))" "$user"
fi
}
allocate_subids_if_missing "${SERVICE_USER}"
# verify rootless Podman UID/GID namespace mappings exist
grep -q "^${SERVICE_USER}:" /etc/subuid || { echo "ERROR: missing subuid mappings for ${SERVICE_USER}"; exit 1; }
grep -q "^${SERVICE_USER}:" /etc/subgid || { echo "ERROR: missing subgid mappings for ${SERVICE_USER}"; exit 1; }
# validate that all subordinate ID ranges in a file are non-overlapping
validate_subid_ranges() {
local file="$1"
awk -F: '
{
start = $2
end = $2 + $3 - 1
for (i = 1; i <= count; i++) {
if (start <= ends[i] && end >= starts[i]) {
printf "ERROR: overlapping ranges in %s\n", FILENAME
printf " %s:%s:%s overlaps %s:%s:%s\n",
$1, $2, $3,
names[i], starts[i], sizes[i]
exit 1
}
}
count++
names[count] = $1
starts[count] = start
ends[count] = end
sizes[count] = $3
}
' "$file"
}
validate_subid_ranges /etc/subuid
validate_subid_ranges /etc/subgid
# configure the rootless storage driver BEFORE Podman first initializes its storage.
# fuse-overlayfs is a reliable rootless overlay backend and avoids kernel-version
# quirks of native rootless overlay on ARM/Armbian. It is optional with the default
# userns used here, but recommended; set it before the first storage init so no
# 'podman system reset' is needed later.
sudo -u "${SERVICE_USER}" mkdir -p "${SERVICE_HOME}/.config/containers"
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/storage.conf" >/dev/null <<'EOF'
[storage]
driver = "overlay"
[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs"
EOF
# rebuild rootless Podman state to ensure existing containers/storage use current namespace mappings
# use a login shell (-i) so HOME points at the service user's home
# (storage now initializes with fuse-overlayfs; no 'podman system reset' needed)
sudo -iu "${SERVICE_USER}" podman system migrate
Configure Secure Service Directory Permissions
# Apply the admin/service ACL policy to one directory tree, deterministically.
# $1 = tree to treat
# $2 = (optional) a sub-path to skip entirely (e.g. a container data subtree)
#
# Why setfacl, not chmod: on an ACL'd tree, chmod edits only the mask, never group::,
# so stale execute bits survive. setfacl sets every entry explicitly (mask included),
# lowercase β the result is reproducible and no execute bit can resurface.
apply_acl_tree() {
local tree="$1"
local skip="${2:-}"
local prune=()
[ -n "$skip" ] && prune=( -path "$skip" -prune -o ) # skip this subtree if a 2nd arg is given
sudo find "$tree" "${prune[@]}" -exec chown "${SERVICE_USER}:${SERVICE_ADMIN}" {} + # service user owns; admin group is the owning group
# directories β access ACL (perms on the existing dirs)
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -m g:${SERVICE_ADMIN}:rwx {} + # admin group: access existing dirs
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -m u:${SERVICE_USER}:rwx {} + # service user: access existing dirs
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -m u::rwx,g::rwx,o::-,m::rwx {} + # owner/group rwx, deny others, pin mask rwx
# directories β default ACL (inherited by NEW files/dirs created later)
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m g:${SERVICE_ADMIN}:rwx {} + # admin group: inherit access on new items
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m u:${SERVICE_USER}:rwx {} + # service user: inherit access on new items
sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m u::rwx,g::rwx,o::- {} + # default owner/group/other for new items
# files β access ACL (rw only, never executable)
sudo find "$tree" "${prune[@]}" -type f -exec setfacl -m g:${SERVICE_ADMIN}:rw {} + # admin group: access existing files
sudo find "$tree" "${prune[@]}" -type f -exec setfacl -m u:${SERVICE_USER}:rw {} + # service user: access existing files
sudo find "$tree" "${prune[@]}" -type f -exec setfacl -m u::rw,g::rw,o::-,m::rw {} + # owner/group rw, deny others, pin mask rw
sudo find "$tree" "${prune[@]}" -type d -exec chmod g+s {} + # setgid: new items inherit the group (a MODE bit; ACLs can't set it)
}
# --- SERVICE_HOME: Podman's private runtime home ------------------------------
# Own the home + set setgid, then ACL ONLY the .config subtree (containers.conf,
# storage.conf, and the *.network / *.container quadlets you hand-edit) so admins
# can edit them sudo-less.
#
# Leave ~/.local (storage) and ~/.cache to Podman: ACLs on the overlay store break
# the runtime ("OCI permission denied" on the `merged` mount). Admin reaches them via sudo.
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_HOME}" # service user owns; admin group is the owning group
sudo chmod u=rwx,g=rwx,o=,g+s "${SERVICE_HOME}" # owner/admin access; deny others; inherit group on new items
apply_acl_tree "${SERVICE_HOME}/.config" # .config subtree: full sudo-less admin ACL treatment
# --- bind-mount data dirs: created + owned BEFORE the SERVICE_DIR ACL pass ---------
# Each container writes as a mapped subuid, so its data dir must be owned by that subuid
# (chown as root; only root crosses the id range). We create + own them FIRST so they
# neither inherit SERVICE_DIR's default ACL (inheritance happens at creation) nor get
# walked by apply_acl_tree (which prunes data/).
#
# No ACLs here: Postgres refuses a group-accessible PGDATA. Admin reaches them via sudo;
# back Postgres up with pg_dumpall, not a raw file copy.
SUBUID_BASE="$(awk -F: -v u="${SERVICE_USER}" '$1==u {print $2}' /etc/subuid)"
SUBGID_BASE="$(awk -F: -v u="${SERVICE_USER}" '$1==u {print $2}' /etc/subgid)"
# data subdir : in-container uid/gid it must be owned by
data_dirs=(
"postgres-db:70" # /var/lib/postgresql β postgres user (pinned User=70:70)
"tripwise:1000" # /data/tripwise β tripwise uploads directory (image USER 1000)
)
mkdir -p "${SERVICE_DIR}/data" # the data/ parent: plain, no ACL
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_DIR}/data" # owner/admin
sudo chmod 0750 "${SERVICE_DIR}/data" # children are set individually below
for entry in "${data_dirs[@]}"; do
sub="${entry%%:*}" # subdir name
ids="${entry#*:}" # "uid" or "uid:gid"
uid="${ids%%:*}" # uid
gid="${ids#*:}"; [ "$gid" = "$ids" ] && gid="$uid" # gid (defaults to uid)
dir="${SERVICE_DIR}/data/${sub}"
sudo mkdir -p "$dir"
sudo chown -R "$((SUBUID_BASE + uid - 1)):$((SUBGID_BASE + gid - 1))" "$dir" # map both to host subids
sudo chmod 0700 "$dir"
done
# --- SERVICE_DIR: application data, env files, configuration -------------------
# Full treatment so the admin group gets sudo-less access AND the service user keeps
# access even on admin-created files. data/ is EXCLUDED: those dirs are owned by the
# containers' subuids and need engine-specific modes (Postgres rejects a group/other-
# accessible PGDATA), so they are handled separately just below.
apply_acl_tree "${SERVICE_DIR}" "${SERVICE_DIR}/data" # treat everything EXCEPT data/
Configure Secure NGINX Static Assets Permissions
# Grant the NGINX worker user (www-data) read-only access to assets subtrees.
# $1 = service directory β traverse-only (x)
# $2..$n = assets subtrees served directly by NGINX (configs, icons, favicon, manifest, ...)
apply_acl_nginx() {
local tree="$1"; shift
local assets
sudo setfacl -m u:www-data:x "$tree" # traverse only: pass through, no listing, no reading
for assets in "$@"; do
# assets directories β enter + list, existing and future
sudo find "$assets" -type d -exec setfacl -m u:www-data:rx {} + # www-data: read existing dirs
sudo find "$assets" -type d -exec setfacl -d -m u:www-data:rx {} + # www-data: inherit read on new items
# assets files β www-data read-only, owner/admin rw, never executable.
# Entries AND mask are pinned explicitly: a bare `setfacl -m u:www-data:r`
# would recalculate the mask to the union of all entries, resurfacing the
# latent rwx inherited from the default ACL (files would show group rwx).
sudo find "$assets" -type f -exec setfacl \
-m u:www-data:r,u:${SERVICE_USER}:rw,g:${SERVICE_ADMIN}:rw,u::rw,g::rw,o::-,m::rw {} +
done
}
# --- SERVICE_DIR/nginx: served directly by NGINX -----------
# Created AFTER the apply_acl_tree pass so they inherit the default ACL + setgid
# group (service user/admin keep full access); www-data is then layered on top
# as a read-only named entry.
sudo mkdir -p "${SERVICE_DIR}/nginx"
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_DIR}/nginx"
apply_acl_nginx "${SERVICE_DIR}" "${SERVICE_DIR}/nginx"
π‘οΈ Rootless Podman Defaults¶
Configure rootless Podman per-user configuration directory
# configure rootless Podman defaults for this service user
# - store per-user Podman configuration under ~/.config/containers
sudo -u "${SERVICE_USER}" mkdir -p "${SERVICE_HOME}/.config/containers/systemd"
Configure rootless Podman defaults
# configure rootless Podman defaults for this service user
# - use k8s-file logging for easier log rotation and inspection
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/containers.conf" >/dev/null <<EOF
[containers]
log_driver = "k8s-file"
[engine]
healthcheck_events=false
EOF
π§ Service Configuration¶
Setup TripWise Parameters
Before deploying, you need to define a few environment variables that will be used throughout the setup process.
BASE_URL: public URL where the web service is accessibleHOST_PORT: external port used by NGINX to route traffic to the serviceADMIN_EMAIL: the default admin account (email only β there is no admin password in the env)- SMTP settings: required so the admin and invited users receive their activation links (startup aborts if the relay is unreachable)
###################################################################################
# NGINX Proxy Configuration
###################################################################################
HOST_PORT=10060
###################################################################################
# Postgres Configuration
###################################################################################
PG_VERSION=18-alpine
PG_DB=tripwise
PG_USER=tripwise
PG_PASSWORD="$(openssl rand -hex 32)"
###################################################################################
# TripWise Configuration
###################################################################################
BASE_URL=https://tripwise.domain.fr
ADMIN_EMAIL=admin@domain.fr
###################################################################################
# SMTP Configuration
###################################################################################
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USER=tripwise@domain.fr
SMTP_PASSWORD=your-smtp-password
SMTP_FROM=tripwise@domain.fr
Getting Your Gmail SMTP Password
To send emails via Gmail SMTP, you'll need to generate an App Password.
A special password used for third-party applications.
Visit myaccount.google.com/apppasswords to create one.
Use the generated password in the SMTP_PASSWORD field of your configuration.
Create the Environment file
sudo -u "${SERVICE_USER}" tee "${SERVICE_DIR}/postgres.env" >/dev/null <<EOF
###################################################################################
# PostgreSQL Database Configuration
###################################################################################
POSTGRES_VERSION=${PG_VERSION}
POSTGRES_DB=${PG_DB}
POSTGRES_USER=${PG_USER}
POSTGRES_PASSWORD=${PG_PASSWORD}
EOF
sudo -u "${SERVICE_USER}" tee "${SERVICE_DIR}/tripwise.env" >/dev/null <<EOF
###################################################################################
# NGINX Proxy Configuration
###################################################################################
HOST_PORT=${HOST_PORT}
###################################################################################
# Base image versions (build)
###################################################################################
RUST_VERSION=1.98.0
NODE_VERSION=24
DEBIAN_TAG=trixie-slim
###################################################################################
# TripWise Configuration
###################################################################################
TRIPWISE_BASE_URL=${BASE_URL}
TRIPWISE_COOKIE_SECURE=true
# The signed-out pages' theme (system, dark, light, or daytime: dark in the night window, on the device's clock).
# TRIPWISE_LOGIN_THEME=daytime
# TRIPWISE_LOGIN_NIGHT=18:00-08:00
###################################################################################
# Default admin (seeded 'pending' on first start)
###################################################################################
TRIPWISE_ADMIN_EMAIL=${ADMIN_EMAIL}
###################################################################################
# SMTP (required for new-user validation emails)
###################################################################################
SMTP_HOST=${SMTP_HOST}
SMTP_PORT=${SMTP_PORT}
SMTP_SECURITY=${SMTP_SECURITY}
SMTP_USER=${SMTP_USER}
SMTP_PASSWORD=${SMTP_PASSWORD}
SMTP_FROM=${SMTP_FROM}
EOF
Keep the .env files
All the secret informations will be stored in the .env files.
π§© Quadlet Service¶
Install the Quadlet units
# the units live in the repository under quadlet/ β every .build unit already
# carries the platform build context:
# PodmanArgs=--build-context platform=/media/ssd/podman/platform
# link them into the service user's systemd directory (the generator and
# podmanctl read them there). Re-runnable: every existing link into quadlet/
# is dropped first β stale or dangling ones included β then the current set
# is linked, replacing a plain file of the same name if one is left over.
# find runs from / : GNU find returns to its starting directory when done, and a
# directory the service user cannot enter (the admin's home, mode 700) fails it β AFTER
# the delete, so a `set -e` shell would stop before relinking
(cd / && sudo -u "${SERVICE_USER}" find "${SERVICE_HOME}/.config/containers/systemd" -maxdepth 1 -type l -lname "${SERVICE_DIR}/quadlet/*" -delete)
sudo -u "${SERVICE_USER}" ln -sf "${SERVICE_DIR}"/quadlet/* "${SERVICE_HOME}/.config/containers/systemd/"
Symlinks, so a pull that edits a unit is live at the next
systemctl --user daemon-reload(podmanctl --updatereloads after its pull). A pull that adds, renames or removes a unit needs the two lines again, then the reload β quadlet changes are rare and deliberate.
Enable and Start Quadlet Services
# open an interactive shell as the service user
sudo -iu "${SERVICE_USER}"
# reload systemd user units
systemctl --user daemon-reload
# build the tripwise images (first install; podmanctl --update rebuilds afterwards)
systemctl --user start tripwise-build.service
systemctl --user start tripwise-gmaps-build.service
systemctl --user start tripwise-osrm-build.service
# start Podman Quadlet services
systemctl --user start tripwise-server.service
# verify service status
systemctl --user status tripwise-server.service
To follow the service logs in real time, run
sudo journalctl _UID=$(id -u ${SERVICE_USER}) -ffrom the Debian account.
π Deploy TripWise¶
Install NGINX
NGINX needs to be installed, follow the NGINX section.
Configure NGINX
NGINX needs to be configured using a file in /etc/nginx/sites-enabled directory.
This configuration file specify the documentation path:
# Per-client rate-limit bucket for the auth endpoints (login is Argon2-costly;
# activate consumes single-use tokens). Must live in http{} context, so it sits
# outside the server{} block.
#
# Every zone below keys on $limit_key, the shared key from nginx.conf: the address
# for IPv4, the /64 prefix for IPv6 β keying on a full IPv6 address would let one
# subscriber prefix mint 2^64 empty buckets and bypass the limits entirely.
limit_req_zone $limit_key zone=tripwise_auth:10m rate=10r/m;
# Per-client connection bucket for the SSE liveness stream: /api/events is public
# and each connection stays open, so an anonymous client could otherwise pin a
# worker connection per stream. The app caps the total itself (events.rs) β this
# keeps a single source from eating that whole budget.
limit_conn_zone $limit_key zone=tripwise_events:10m;
# Per-client bucket for the liveness probe. Its own zone, NOT a share of the
# auth one: while offline the app probes /api/health every 15 s per tab, and at
# the auth zone's 10r/m that traffic ate the bucket a login needs β a household
# with a few tabs open could 429 its own sign-in. Generous on purpose (the app
# probes only while it believes it is offline); the point is a ceiling, not a brake.
limit_req_zone $limit_key zone=tripwise_health:10m rate=60r/m;
# Per-client connection bucket for the authenticated sync stream
# (/api/events/sync, the platform's sync contract). Its own zone rather than a
# share of the one above: every signed-in tab holds one of each, so a single
# browser would otherwise spend the liveness budget twice over. Each of these
# also costs a forwarding task, not just a socket (events.rs).
limit_conn_zone $limit_key zone=tripwise_sync_events:10m;
# Per-client bucket for the upload routes. Each request streams to a temp file
# before its bytes are accepted, so a burst of cancelled uploads is the cheapest
# way to churn disk; the app bounds the staged total (the platform file store's
# 512 MB staging ceiling, contracts/files.md) and this bounds the arrival rate.
# The device's file queue sends one file at a time, and a 429 only defers the
# rest of its queue to its next trigger.
#
# Only the writing methods are counted. Reads stage nothing, and the trip export
# pulls the cover thumbnail plus every item's attachment list and every
# attachment's bytes back to back β far past burst=20 at this zone's 1r/s
# refill, so the download half of the location below answered 429 mid-export.
# limit_req skips a request whose key is empty, which exempts GET/HEAD while
# the writes β the file queue's PUTs, PATCH and DELETE β keep exactly the brake
# this zone was written for.
map $request_method $tripwise_upload_key {
default $limit_key;
GET "";
HEAD "";
}
limit_req_zone $tripwise_upload_key zone=tripwise_uploads:10m rate=60r/m;
# The app container, published on the loopback by its Quadlet unit. It sits
# behind an upstream block for one reason: `keepalive`. Without it nginx opens β
# and immediately closes β a brand-new TCP connection for EVERY proxied request,
# and on this host each of those crosses rootless Podman's userspace port
# forwarder. A trip page pulls its thumbnails, avatars and attachments on
# top of the SPA assets, so a single page load paid a handshake per image
# through that forwarder.
# `proxy_http_version 1.1` (already set in every location) and
# `proxy_set_header Connection ""` are what let a connection go back into this
# pool instead of being torn down.
upstream tripwise_app {
server 127.0.0.1:10060;
keepalive 32;
keepalive_requests 1000;
keepalive_timeout 60s;
}
server {
server_name tripwise.domain.fr;
# Security headers; `always` also covers the NGINX-served error pages. No
# Content-Security-Policy here: the app serves its own on every response
# (src/backend/src/web.rs) with the allowances its features need (WASM for
# the JPEG XL decoder, the tile/glyph hosts).
# Browsers enforce EVERY CSP header they receive, so a second, stricter
# proxy policy would silently break those features β keep the app's CSP the
# single source of truth.
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# The app sends these four itself (platform-http's security headers, HSTS
# once its cookies are Secure): hidden here, so each reaches the browser once,
# with the values above. A browser keeps only the FIRST HSTS header it gets,
# and the app's (one year) came first.
proxy_hide_header Strict-Transport-Security;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-Frame-Options;
proxy_hide_header Referrer-Policy;
# setup 404 error_page
error_page 404 /404.html;
include snippets/error-404.conf;
# show maintenance page when backend is down
error_page 502 503 504 /maintenance.html;
include snippets/error-maintenance.conf;
# The backend answers unknown /api/* paths with a JSON 404 and every other path
# with the SPA (200), so the only 404 that ever reaches us from upstream is an
# API error. proxy_intercept_errors is on globally, which would swap that JSON
# body for the HTML page and leave the UI parsing markup as JSON. NGINX's own
# errors are unaffected β an unreachable container still gets the maintenance
# page, because NGINX generates that 502 itself rather than proxying it.
proxy_intercept_errors off;
# Default ceiling for the whole vhost. Everything except the upload routes and
# the sync batch posts small JSON, so 1 MB is generous β and it matters that
# this is the default: nginx buffers a request body to disk BEFORE opening the
# upstream connection, so a server-wide 12m would let an unauthenticated client
# spool 12 MB to client_body_temp against any path and churn the proxy host's
# disk long before the app returns 401. The upload routes and /api/sync raise
# it for themselves below.
client_max_body_size 1m;
# Every unauthenticated auth endpoint, rate-limited before hitting the app.
# `activation/<token>` is included and is why the pattern has no trailing `$` β
# it carries the token in the path and runs a DB query per call. The app also
# keeps a per-IP activation lockout; this is the layer in front of it.
# A route left out of this list is a route with no limit at all: the zone only
# applies where a location names it.
# Regex location (takes precedence over `location /`), so it repeats the proxy
# setup; rejections give 429.
# Keep the port in sync with HOST_PORT in tripwise.env.
location ~ ^/api/auth/(login|activate|forgot-password|confirm-email|activation/) {
limit_req zone=tripwise_auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://tripwise_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# Upload/download routes (thumbnail, avatar, item and trip-level attachments β
# an attachment's own URL is its file slot, a raw PUT from the device's file
# queue) β uploads are rate-limited per source IP so cancelled ones cannot be
# used to churn the staging area; downloads pass freely (see the zone's map
# above). Regex location, so it repeats the proxy setup.
location ~ ^/api/(trips/[^/]+/(thumbnail|attachments|items/[^/]+/attachments)|users/me/avatar) {
limit_req zone=tripwise_uploads burst=20 nodelay;
limit_req_status 429;
# Keep above the app's own cap (TRIPWISE_MAX_UPLOAD_MB, default 10 MB, which
# the file store enforces per slot), and stream the body to the app instead
# of spooling it to the proxy's disk; the attachment routes run outside the
# app's 60 s timeout, so client_body_timeout is what ends a stalled body.
client_max_body_size 12m;
proxy_request_buffering off;
proxy_pass http://tripwise_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# SSE liveness stream β long-lived by design, so it is capped per source IP
# and must not be buffered (buffering would swallow the heartbeats and the
# status dot would never turn green).
location = /api/events {
# One per open tab, and under HTTP/2 nginx counts each concurrent request
# as a connection β so this has to clear a normal browser's tab count with
# room for a reload overlapping the stream it is replacing. It was 4, which
# a fourth tab hit: the 429 is FATAL to an EventSource (the spec reconnects
# a dropped connection, never one answered with the wrong status), so that
# tab lost its liveness signal for good and the app blinked offline.
limit_conn tripwise_events 16;
limit_conn_status 429;
proxy_pass http://tripwise_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
proxy_buffering off;
proxy_read_timeout 1h;
}
# SSE sync stream (the platform's sync contract) β the same long-lived,
# unbuffered treatment as the liveness stream above (buffering would hold the
# `sync` events until the connection closed, which is never), but
# authenticated and on its own connection bucket. An exact-match location: it
# would otherwise fall through to `location /` and be buffered there.
location = /api/events/sync {
limit_conn tripwise_sync_events 8;
limit_conn_status 429;
proxy_pass http://tripwise_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
proxy_buffering off;
proxy_read_timeout 1h;
}
# The sync round trip (trips and everything under them): up to 500 changes per
# collection in one JSON body β a trip planned on a phone that was offline for
# weeks β so the vhost's 1 MB ceiling is not enough here. A 413 would count as
# a failed round on the phone and keep its outbox waiting. The app caps the
# same 8 MB itself (web.rs SYNC_BODY_LIMIT_MB).
location = /api/sync {
client_max_body_size 8m;
proxy_pass http://tripwise_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# Liveness probe β public because the UI seeds its status dot with it, but
# every hit runs a DB query against a 5-connection pool, so it is rate-limited:
# a flood would otherwise saturate the pool and stall real requests (and the
# container healthcheck probes the same route, with HealthOnFailure=kill).
# Its own zone rather than the auth one it used to share β see that zone's
# comment: the app's own offline probing was spending the login budget.
location = /api/health {
limit_req zone=tripwise_health burst=20 nodelay;
limit_req_status 429;
proxy_pass http://tripwise_app;
proxy_http_version 1.1;
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
# reverse proxy
location / {
proxy_pass http://tripwise_app;
# keep it HTTP/1.1
proxy_http_version 1.1;
# forwarded headers. proxy_params APPENDS to the client's own
# X-Forwarded-For, so a caller can prepend whatever hops it likes; NGINX is
# the edge here and there is no upstream chain worth keeping, so overwrite
# it with the real peer and the app can never be handed a forged hop.
include proxy_params;
# Empty Connection header: keeps the pooled upstream connection alive.
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
}
}
# restart nginx
sudo nginx -t && sudo service nginx restart
Replace
tripwise.domain.frby the name of your website, and10060byHOST_PORT.
Activate HTTPS
To activate HTTPS protocol, follow the Let's Encrypt section.
First login β activate the admin account
The admin account is created pending on first start (from TRIPWISE_ADMIN_EMAIL).
There is no admin password in tripwise.env. To activate it:
- On first start, the server e-mails an activation link to
TRIPWISE_ADMIN_EMAIL(SMTP must be reachable β startup aborts otherwise). - Open the
/activate/:tokenlink from the e-mail, choose a password, and you are signed in. - The link is single-use and expires after
TRIPWISE_ACTIVATION_TTL_HOURS(72 h by default).
Accounts are invite-only β there is no self-registration. Further users and admins are invited from the Users admin page the same way (activation link by e-mail), and a pending account's link can be re-sent from there at any time.
β¬οΈ Updating TripWise¶
TripWise deploys from a dev machine: ./deploy.ps1 builds its three production images β
tripwise, tripwise-gmaps and tripwise-osrm, one deployment under one <app sha>.<platform sha>
tag β publishes them to the fleet registry, then pushes the commit: that
order is the whole invariant.
That push fires the bare repo's hook, which calls podmanctl --deploy tripwise <short-sha>:
it acknowledges the push, then deploys detached. Nothing is built here β
it pulls <registry>/tripwise:<app sha>.<platform sha>, checks its revision labels against the
commit just checked out and starts it; with nothing published it refuses before stopping
anything. Follow a deploy with sudo tail -f /var/log/tripwise-deploy.log.
π Deploying a change
From the app's clone on a dev machine, with the platform clone beside it:
git add -u && git commit -m "..."
./deploy.ps1
deploy.ps1 does everything in order: build all 3 images β push them β git push.
Never push first β the hook would fire before the images exist and the deploy would
be refused. All 3 are built before any is pushed, so a failure never leaves half a
deployment published.
It refuses early rather than wasting a build when the tree is dirty or has diverged from the
server. With nothing left to push it asks the app's /api/version what production runs:
HEAD itself β it exits 3 (skipped); no answer β exit 3 marked unconfirmed, with the reason;
another commit β it publishes the image the server is missing and exits 4, naming the
podmanctl --deploy left to run there. After a push it waits until /api/version reports
the new commit (-NoWait skips the wait).
πͺ One-time setup β hook and clone wiring
The deploy entry point is central and installed once for the whole fleet β
see Podman β Push deploys for
podmanctl --deploy and its single sudoers rule. Only the two steps below
are per-repository.
# install into the bare repo's hooks/ β fires on every push
# ${SERVICE_NAME} expands now; the rest stays literal (\$β¦) for git to expand
sudo tee /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive >/dev/null <<EOF
#!/bin/sh
set -eu
DEPLOY_BRANCH=refs/heads/master
while read -r oldrev newrev refname; do
[ "\$refname" = "\$DEPLOY_BRANCH" ] || continue # only the deploy branch
sudo /usr/local/bin/podmanctl --deploy ${SERVICE_NAME} "\$(git rev-parse --short "\$newrev")" </dev/null
done
EOF
# normalize the hook ownership
sudo chown www-data:debian /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive
sudo chmod 0750 /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive
# git seeded hooks/ with executable *.sample files β inert, but strip the bit
sudo chmod -x /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/*.sample
The commit comes from the just-pushed ref (the bare repo already holds it) and only names the deploy in its log. Nothing else is baked in:
podmanctlderives the service directory, user, admin group, env file and log path from the name it is given.
# deploys pull non-interactively as root: pull from the co-located bare repo over a
# LOCAL path (the HTTPS origin needs a credential prompt + TTY, which a hook lacks)
git -C ${SERVICE_DIR} remote set-url origin /media/ssd/git/podman/${SERVICE_NAME}.git
# trust both repos for root so the root-run `git pull` doesn't refuse with
# "detected dubious ownership"; --system applies whatever HOME the hook runs with
sudo git config --system --add safe.directory ${SERVICE_DIR}
sudo git config --system --add safe.directory /media/ssd/git/podman/${SERVICE_NAME}.git
Without the local origin the pull dies with could not read Username for 'https://β¦'; without
safe.directoryit dies with detected dubious ownership. Either way the worker aborts beforepodmanctl --updateand leaves the service running on its previous build.
π§± Upgrading the base images
Every image the build starts FROM is pinned in tripwise.env β
rust:${RUST_VERSION}-slim-trixie, node:${NODE_VERSION}-alpine, debian:${DEBIAN_TAG},
alongside the stack's postgres:${POSTGRES_VERSION}. The .build units pass them as
--build-arg and build/Dockerfile carries matching ARG defaults, so nothing moves under
the fleet on its own. TripWise builds on a dev machine, so the bases are pulled there;
here --upgrade still refreshes postgres:${POSTGRES_VERSION}. Refreshing is a
deliberate step:
podmanctl --list --check # UPDATE column shows when an upstream image moved
podmanctl --upgrade tripwise # pull bases + registry images, then rebuild + restart
The env files are gitignored: add a new key on the server before the push that needs it, or a quadlet expanding
${RUST_VERSION}against a missing key buildsrust:-slim-trixieand fails. A bumpedRUST_VERSIONinvalidates the whole Rust layer chain β toolchain, every dependency crate (a fresh cargo cache) β so that rebuild runs 15 min+ on the ARM host: schedule it, don't treat it as a routine deploy. Edit and push a pin first (build/Dockerfile,tripwise.env,postgres.env), then--upgrade.
β Platform conformance
TripWise consumes the shared platform β the platform-* crates and @platform/* packages its
image is built from β so the platform's conformance suites must stay green for it. They run
on the local stack only, never against production (2026-10-01): from the shared-core
checkout, before every deploy,
./scripts/fleet-check.ps1 -Apps tripwise -UseVsClang
It starts
run-local.ps1 -Detach -ResetDevAdmin, runs the HTTP suite, the browser suite and this app's owne2e/through the stack's own nginx (https://127.0.0.1:8445), checks that nginx itself, then stops the stack. The whole fleet at once: Release & rollout β Fleet conformance.