This app is intended to run as a Docker Compose stack on an Ubuntu server. The stack includes:
postgres: optional, default-on bundled PostgreSQL database through thelocal-postgresCompose profile.app: FastAPI mileage logger.nginx: web service reverse proxy that serves the web app on HTTP port80.cloudflared: Cloudflare Tunnel connector for public HTTPS access.- Daily Michigan gas price snapshots run as a background scheduler in the app container.
- Console-only app logging for Docker or Swarm log collection.
- A host app-data bind mount for automatic backups and health-monitor state.
- Ubuntu server with network access.
- Docker Engine with the Docker Compose plugin.
- A DNS name or static IP address for OwnTracks to reach the server.
- Port
80open to the network where your phone will connect.
For internet-facing use, put HTTPS in front of this stack before using it with real location data. OwnTracks HTTP mode uses Basic Auth plus payload encryption, but credentials and metadata should still travel over TLS.
If Docker is not installed yet, install it from Docker's Ubuntu repository or from Ubuntu packages. The simplest package-based install is:
sudo apt-get update
sudo apt-get install -y docker.io docker-compose-v2
sudo systemctl enable --now dockerConfirm Docker Compose works:
docker compose versionIf you want to run Docker without sudo, add your user to the docker group and log out/in:
sudo usermod -aG docker "$USER"Clone the repository on the server:
git clone https://github.com/cosmicc/Mileage-Logger.git
cd Mileage-LoggerGenerate a production .env file:
./scripts/init_docker_env.shThis creates .env from .env.docker.example and generates values for:
SECRET_KEYWEB_LOGIN_PASSWORDWEB_API_KEYPOSTGRES_PASSWORDOWNTRACKS_PASSWORDOWNTRACKS_ENCRYPTION_KEY
The generated .env keeps COMPOSE_PROFILES=local-postgres, which deploys the bundled
PostgreSQL container. For a central PostgreSQL server, set COMPOSE_PROFILES= and update
DATABASE_URL before deploying.
It also tries to prepare HOST_DATA_DIR and the separate HOST_BACKUP_DIR. If your
user cannot write to /var/lib, create them before
starting Docker:
sudo install -d -m 0750 /var/lib/mileage-logger
sudo install -d -m 0750 /var/lib/mileage-logger/backupsWhen upgrading from an earlier release, move retained automatic backups from
/var/log/mileage-logger/backups to /var/lib/mileage-logger/backups before the first v1.3.4
deployment, or set HOST_DATA_DIR to the existing host directory. Earlier login audit log files
are no longer read or written; new login audits begin in PostgreSQL after the migration.
Before upgrading from a release that still has the server-side OwnTracks buffer, keep PostgreSQL
online and confirm both Diagnostics queue counts are zero. Do not deploy v1.4.0 or later
while either old queue contains data, because the new release intentionally has no replay worker.
After the upgrade is verified, the old host owntracks-buffer directory and Docker
owntracks_buffer_fallback volume are unused and can be archived or removed.
Review the file before starting, and set CLOUDFLARED_TUNNEL_TOKEN to the token from the
Cloudflare dashboard:
nano .envImportant values:
HTTP_PORT=80
WEB_ALLOWED_CIDRS=
SECRET_KEY=<generated-session-secret>
WEB_LOGIN_USERNAME=admin
WEB_LOGIN_PASSWORD=<generated-web-password>
WEB_SESSION_COOKIE_SECURE=true
WEB_LOGIN_MAX_ATTEMPTS=5
WEB_LOGIN_LOCKOUT_SECONDS=300
PASSKEY_RP_NAME=Mileage Logger
PASSKEY_RP_ID=
PASSKEY_ORIGIN=
CLOUDFLARE_IP_BLOCKING_ENABLED=false
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ZONE_ID=
CLOUDFLARE_IP_BLOCK_ALLOWLIST=
CLOUDFLARE_AUTO_BLOCK_FAILED_LOGIN_ATTEMPTS=5
PUSHOVER_ENABLED=false
PUSHOVER_TOKEN=
PUSHOVER_USER=
PUSHOVER_APP_KEY=
PUSHOVER_USER_KEY=
PUSHOVER_DEVICE=
PUSHOVER_PRIORITY=0
APP_HEALTH_MONITOR_INTERVAL_SECONDS=60
APP_HEALTH_DB_LATENCY_WARNING_MS=500
APP_HEALTH_DB_LATENCY_CRITICAL_MS=2000
APP_HEALTH_DB_LATENCY_SUSTAINED_SECONDS=15
APP_HEALTH_DISK_WARNING_FREE_MB=1000
APP_HEALTH_DISK_CRITICAL_FREE_MB=250
APP_HEALTH_STATE_PATH=/data/app-health-state.json
OWNTRACKS_USERNAME=owntracks
OWNTRACKS_PASSWORD=<generated-password>
OWNTRACKS_SYNC_WAYPOINTS=true
AUTOMATIC_TRIP_PROCESSING_ENABLED=true
AUTOMATIC_TRIP_PROCESSING_INTERVAL_SECONDS=60
OWNTRACKS_PURGE_ENABLED=true
OWNTRACKS_LOCATION_RETENTION_DAYS=90
APP_DATA_DIR=/data
HOST_DATA_DIR=/var/lib/mileage-logger
HOST_BACKUP_DIR=/var/lib/mileage-logger/backups
AUTOMATIC_BACKUPS_ENABLED=true
AUTOMATIC_BACKUP_DIR=/data/backups
AUTOMATIC_BACKUP_RETRY_SECONDS=60
MAX_BACKUP_RESTORE_BYTES=262144000
LOG_LEVEL=info
GAS_PRICE_SOURCE=aaa_current
VEHICLE_MPG=25.0
REPORT_DISPLAY_NAME=
GAS_SNAPSHOT_ENABLED=true
GAS_SNAPSHOT_INTERVAL_SECONDS=86400
GAS_SNAPSHOT_RUN_ON_STARTUP=true
CLOUDFLARED_TUNNEL_TOKEN=
CLOUDFLARED_LOG_LEVEL=info
CLOUDFLARED_METRICS=
CLOUDFLARED_TRANSPORT_PROTOCOL=autoProduction starts fail closed when SECRET_KEY is still change-me, when one web login field is
blank, when both web login fields are missing, when WEB_API_KEY is missing, or when
OWNTRACKS_ENCRYPTION_KEY plus OwnTracks Basic Auth credentials are missing. Docker publishes the web service
only on 127.0.0.1, so public access should come through the bundled Cloudflare Tunnel service.
Passkeys are optional. Create them from Diagnostics after username/password login. In normal
Cloudflare Tunnel Docker use, the web service forwards the public HTTPS origin for WebAuthn. If your proxy
does not, set PASSKEY_ORIGIN=https://your-host.example.com and
PASSKEY_RP_ID=your-host.example.com.
The generated OWNTRACKS_USERNAME, OWNTRACKS_PASSWORD, and OWNTRACKS_ENCRYPTION_KEY are what
you enter in OwnTracks HTTP mode. Do not reuse OWNTRACKS_ENCRYPTION_KEY as WEB_API_KEY; the
latter is only for non-OwnTracks API routes through Authorization: Bearer <WEB_API_KEY>.
The web service container exposes rendered web pages and the OwnTracks ingestion API. The public web service only forwards these API requests:
POST /api/owntracksPOST /api/owntracks/POST /api/pubPOST /api/pub/
All other /api/ routes, /docs, /redoc, and /openapi.json return 404 through the web service.
Internal app health checks still call /api/health directly inside the app container. Non-OwnTracks
API routes still require Authorization: Bearer <WEB_API_KEY> when called from inside the Docker
network or another trusted internal path.
The web service serves custom styled error pages for 400, 401, 403, 404, 405, 408, 413, 429,
500, 502, 503, and 504 responses. The pages explain the error and include a link back to /login.
App-generated JSON API errors are not globally intercepted, so API clients such as OwnTracks can
still receive machine-readable responses from the app.
You can restrict browser UI pages to specific IP blocks while keeping OwnTracks ingestion open.
Set WEB_ALLOWED_CIDRS to comma-separated CIDR blocks:
WEB_ALLOWED_CIDRS=192.168.1.0/24,10.8.0.0/24,203.0.113.44/32With this set:
- OwnTracks ingestion endpoints remain reachable from any IP so OwnTracks can keep sending data.
/,/trips,/waypoints,/diagnostics,/static/, and other web UI paths require a matching client IP.
Leave WEB_ALLOWED_CIDRS blank to keep the current behavior and allow all clients to access the
web UI.
If this stack is behind another reverse proxy, the web service will usually see that proxy's IP address
instead of the original client IP. In that setup, enforce IP restrictions at the outer proxy or
include the proxy's address in WEB_ALLOWED_CIDRS.
For web-login audit records, temporary lockouts, and automatic Cloudflare blocks, the web service passes
Cloudflare's CF-Connecting-IP header through to the app when present. The app uses that IP,
otherwise it falls back to the direct loopback/tunnel client.
Build and start everything:
docker compose up -d --buildCheck status:
docker compose psExpected result:
postgreshealthy.mlapphealthy.mlnginxrunning.cloudflaredrunning.
Open the app:
http://your-server/
The app container runs database migrations automatically on startup.
Portainer can deploy this repository directly from GitHub using docker-compose.yml.
The Compose file does not use env_file, so Portainer does not need a .env file mounted beside
the stack.
In Portainer:
- Go to
Stacks. - Add a new stack.
- Choose the Git repository option.
- Repository URL:
https://github.com/cosmicc/Mileage-Logger.git
- Compose path:
docker-compose.yml
- Import or enter the environment variables from
.env.docker.example. - Change these required secret values before deploying:
SECRET_KEYWEB_API_KEYDATABASE_URLOWNTRACKS_PASSWORDOWNTRACKS_ENCRYPTION_KEYCLOUDFLARED_TUNNEL_TOKENPOSTGRES_PASSWORDwhen using the default bundled PostgreSQL profile
- Optional: set
WEB_ALLOWED_CIDRSto restrict web UI access while keeping OwnTracks ingestion open. - Deploy the stack.
If you change POSTGRES_PASSWORD, make sure DATABASE_URL uses the same password:
POSTGRES_PASSWORD=your-db-password
DATABASE_URL=postgresql+psycopg://mileage:your-db-password@postgres:5432/mileage_loggerTo use a central PostgreSQL server on your network, set COMPOSE_PROFILES= so Compose skips the
bundled postgres service, then point DATABASE_URL at the remote server:
COMPOSE_PROFILES=
DATABASE_URL=postgresql+psycopg://mileage:your-db-password@central-db-host:5432/mileage_loggerThe app waits for and runs migrations against the configured DATABASE_URL. When
COMPOSE_PROFILES= is blank, POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD are ignored by
Compose because the bundled PostgreSQL container is not deployed. For a network database, tune
DATABASE_POOL_SIZE, DATABASE_MAX_OVERFLOW, DATABASE_POOL_TIMEOUT_SECONDS,
DATABASE_POOL_RECYCLE_SECONDS, DATABASE_CONNECT_TIMEOUT_SECONDS, and DB_WAIT_TIMEOUT_SECONDS
only if the central server or network latency requires different limits.
If the database password contains URL-reserved characters, encode it before adding it to
DATABASE_URL; for example, @ becomes %40, : becomes %3A, / becomes %2F, and %
becomes %25.
If the configured database is unreachable at startup, Docker starts the app in outage mode instead
of stopping the container. Browser pages show a responsive service-unavailable page, non-OwnTracks
API routes return 503 JSON, and OwnTracks HTTP requests receive a fast retryable 503 with
Retry-After: 30. The OwnTracks mobile app retains unsuccessful messages and resends them later.
Before a recovered endpoint accepts a message, the app verifies Alembic migrations, stores the
payload in PostgreSQL, and only then returns 200. Exact HTTP retries do not create duplicate raw
events. Automatic trip processing, gas snapshots, and automatic backups pause their
database-writing passes while PostgreSQL is unreachable.
Keep APP_HEALTHCHECK_START_PERIOD longer than DB_WAIT_TIMEOUT_SECONDS. This gives the
entrypoint time to wait for PostgreSQL and then start limp mode before Docker or Swarm counts
healthcheck failures against the app task.
The app will receive configuration from the environment variables imported into the Portainer stack.
Use docker-stack.yml only for Docker Swarm. Keep docker-compose.yml for normal Compose or
Portainer standalone stacks.
Swarm does not build images during docker stack deploy, does not support Compose profiles, and
does not preserve the normal Compose loopback-only nginx port binding. The Swarm stack therefore
uses image tags and overlay networking. The Build and publish Swarm images GitHub workflow
publishes the app and nginx images to GHCR with the package version, latest, and an immutable
commit-SHA tag. For v1.4.3, use:
APP_IMAGE=ghcr.io/cosmicc/mileage-logger-app:1.4.3
NGINX_IMAGE=ghcr.io/cosmicc/mileage-logger-nginx:1.4.3If the GHCR packages are private, configure GHCR registry credentials in Portainer or authenticate the Swarm deployment with a GitHub token that can read packages.
docker stack deploy does not accept --env-file. In Portainer Swarm mode, enter the variables
from .env.docker.example in the stack environment editor. From the command line, export the
needed variables in the shell before deploying.
Remote PostgreSQL Swarm deployment:
export APP_IMAGE=ghcr.io/cosmicc/mileage-logger-app:1.4.3
export NGINX_IMAGE=ghcr.io/cosmicc/mileage-logger-nginx:1.4.3
export DATABASE_URL=postgresql+psycopg://mileage:url_encoded_password@central-db-host:5432/mileage_logger
docker stack deploy -c docker-stack.yml mileage-loggerBundled PostgreSQL Swarm deployment:
export APP_IMAGE=ghcr.io/cosmicc/mileage-logger-app:1.4.3
export NGINX_IMAGE=ghcr.io/cosmicc/mileage-logger-nginx:1.4.3
export DATABASE_URL=postgresql+psycopg://mileage:your-db-password@postgres:5432/mileage_logger
docker stack deploy -c docker-stack.yml -c docker-stack.local-postgres.yml mileage-loggerFor Swarm, configure the Cloudflare Tunnel public hostname origin service as:
http://mlnginx
This service-name change does not rename any existing Portainer variables. Continue using
APP_IMAGE, NGINX_IMAGE, APP_UID, APP_GID, and HOST_DATA_DIR; v1.4.1 also adds
HOST_BACKUP_DIR. Existing deployments must change the
Cloudflare Tunnel origin from http://nginx to http://mlnginx when updating the stack. Swarm
will replace the former <stack>_app and <stack>_nginx services with <stack>_mlapp and
<stack>_mlnginx; a short interruption is expected during that replacement.
The stack runs two cloudflared replicas with at most one per node, a five-second restart delay,
and start-first rolling updates. No additional tunnel token is required; both replicas use the
existing CLOUDFLARED_TUNNEL_TOKEN.
The Swarm stack intentionally does not publish nginx directly. If you add a published port later,
remember Swarm publishes it on the node interface, not as the normal Compose-only
127.0.0.1:${HTTP_PORT} binding.
Keep HOST_DATA_DIR and HOST_BACKUP_DIR available on every Swarm node that can run the mlapp
task. The optional
postgres_data named volume is node-local unless your Swarm volume driver provides shared storage.
The Swarm mlapp task runs as ${APP_UID:-1000}:${APP_GID:-100}. Set APP_UID and APP_GID in the
Portainer stack environment when your shared-storage ownership differs, and ensure both host
directories already exist and are writable by that identity. To remove the old shared-storage
layout, set HOST_DATA_DIR to the mileage-logger directory and set HOST_BACKUP_DIR to its
backups child. v1.4.1 starts a fresh automatic backup set there; it does not move
files from mileage-logger/logs/backups.
The diagnostics page is available at:
http://your-server/diagnostics
It shows app status, recent database records, and database-backed web-login audits.
In OwnTracks on Android:
- Set connection mode to
HTTP. - Set the URL to:
http://your-server/api/owntracks
- Set HTTP Basic Auth credentials:
- Username: value of
OWNTRACKS_USERNAMEin.env - Password: value of
OWNTRACKS_PASSWORDin.env
- Username: value of
- Set payload encryption:
- Encryption key: value of
OWNTRACKS_ENCRYPTION_KEYin.env
- Encryption key: value of
- Set Identification:
- Username: your name or short ID, for example
ian - Device name: your phone name, for example
pixel - Tracker ID: two letters, for example
IP
- Username: your name or short ID, for example
- Set monitoring mode to
Move. - Grant location permission
Allow all the time. - Disable Android battery optimization for OwnTracks.
- Publish a test payload or trigger a waypoint transition to confirm the server receives OwnTracks.
For work waypoints, add OwnTracks regions/waypoints on the phone. Keep OwnTracks location reporting enabled so the app receives location updates between waypoint transitions. OwnTracks sends its HTTP payloads to the configured endpoint, where the app processes supported waypoint, transition, and location messages.
You can also use the Recorder-compatible endpoint:
http://your-server/api/pub
The app supports both /api/owntracks and /api/pub, including their trailing-slash aliases.
Trips are generated from OwnTracks waypoint transition events.
Default behavior:
- A trip is created from a waypoint
leaveevent followed by another waypointenterevent. - The destination
enterevent must be confirmed by at leastOWNTRACKS_WAYPOINT_DWELL_MINUTESminutes of later OwnTracks coordinate data inside that saved waypoint's radius. The default is 5 minutes so driving through a waypoint does not create a trip. OwnTracks region labels by themselves do not confirm a visit. Homeis the exact waypoint name for home.HometoHomeis never a trip.- Trips between the same non-home waypoint are kept.
- If an
enterevent arrives without a matchingleave, the app infers the origin from the previous waypoint. If there is no previous waypoint and the destination is notHome, the app assumes the missed origin wasHome.
Trip generation is automatic. Every incoming OwnTracks location or transition payload is stored in
owntracks_locations and immediately triggers trip recalculation for that payload's
LOCAL_TIMEZONE day. Generated trip rows are stored in trips.
OwnTracks tst event time is the authoritative timestamp for trip dates and ordering; the server
receive time is kept separately for diagnostics because phone data can be buffered.
The server can run on UTC; app day/month selection, dashboard time, and gas
snapshot dates use LOCAL_TIMEZONE, default America/Detroit for EST/EDT.
Generated mileage uses this order:
- OwnTracks location path distance from the location updates received between the waypoint
leaveandenterevents. - Waypoint-to-waypoint distance when OwnTracks path data is not available.
If a trip window has only transition events and no location updates between them, the app falls
back to waypoint distance. Odometer values are never used to calculate trip distance, Dashboard
trip plus non-trip totals, or monthly trip plus non-trip totals. Edit a trip's miles on the
Trips page when the generated mileage needs correction. A distance correction resequences that month's displayed
start and end odometers in chronological trip order. Deleting a trip from the
Trips page also saves an exact deleted-trip record so only that same OwnTracks transition pair
is not generated again; future trips with the same route are still generated normally.
The checkpoint odometer is advanced from OwnTracks path distance between received points even when
those points do not become a trip. Each processed OwnTracks location row stores the rolling
odometer value for that point, and generated trips use those rolling values for start and end
odometers. The trip end odometer is always advanced from the start odometer by the stored trip
distance so the odometer display follows the trip miles. Segments fully inside the same saved
waypoint are ignored to reduce stationary GPS drift. Manual odometer entries on Diagnostics reset
the checkpoint to the entered value and OwnTracks distance continues from that new rolling value.
The Compose file includes cloudflared as a normal required service for a remotely managed
Cloudflare Tunnel. The cloudflared container uses host networking so it can reach the host-bound
web service listener. In the Cloudflare dashboard, publish the application route to the host listener:
http://127.0.0.1:80
The Compose stack always publishes the web service on 127.0.0.1:${HTTP_PORT:-80}. To use a different
local tunnel port, set:
HTTP_PORT=2082Then set the Cloudflare Tunnel service URL to:
http://127.0.0.1:2082
The web service passes Cloudflare's CF-Connecting-IP to the app for login audit records, lockouts, and
automatic Cloudflare blocks. If that header is not present, the app uses the direct tunnel client.
Then set:
CLOUDFLARED_TUNNEL_TOKEN=your-cloudflare-tunnel-token
CLOUDFLARED_LOG_LEVEL=info
CLOUDFLARED_METRICS=
CLOUDFLARED_TRANSPORT_PROTOCOL=autoStart the normal stack:
docker compose up -d --buildThe web app also starts a background processor. It recalculates the current local day on a short
interval and finalizes completed local days. After trip processing updates its checkpoint,
processed OwnTracks location/event rows older than OWNTRACKS_LOCATION_RETENTION_DAYS are purged
automatically, with an enforced minimum retention of 90 days. Trips, odometer fields, waypoints,
reports, gas price records, monthly OwnTracks summary rollups, backups, and other derived app data
are not removed by this purge.
Configuration:
OWNTRACKS_SYNC_WAYPOINTS=true
OWNTRACKS_DEFAULT_SITE_RADIUS_M=150
LOCAL_TIMEZONE=America/Detroit
AUTOMATIC_TRIP_PROCESSING_ENABLED=true
AUTOMATIC_TRIP_PROCESSING_INTERVAL_SECONDS=60
OWNTRACKS_PURGE_ENABLED=true
OWNTRACKS_LOCATION_RETENTION_DAYS=90
OWNTRACKS_WAYPOINT_DWELL_MINUTES=5
OWNTRACKS_TRAVEL_DISTANCE_M=50.0
WEB_LOGIN_USERNAME=admin
WEB_LOGIN_PASSWORD=change-web-login-password
WEB_SESSION_COOKIE_SECURE=true
WEB_LOGIN_MAX_ATTEMPTS=5
WEB_LOGIN_LOCKOUT_SECONDS=300
PASSKEY_RP_NAME=Mileage Logger
PASSKEY_RP_ID=
PASSKEY_ORIGIN=
CLOUDFLARE_IP_BLOCKING_ENABLED=false
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ZONE_ID=
CLOUDFLARE_IP_BLOCK_ALLOWLIST=
CLOUDFLARE_AUTO_BLOCK_FAILED_LOGIN_ATTEMPTS=5
PUSHOVER_ENABLED=false
PUSHOVER_TOKEN=
PUSHOVER_USER=
PUSHOVER_APP_KEY=
PUSHOVER_USER_KEY=
PUSHOVER_DEVICE=
PUSHOVER_PRIORITY=0
APP_HEALTH_MONITOR_INTERVAL_SECONDS=60
APP_HEALTH_DB_LATENCY_WARNING_MS=500
APP_HEALTH_DB_LATENCY_CRITICAL_MS=2000
APP_HEALTH_DB_LATENCY_SUSTAINED_SECONDS=15
APP_HEALTH_DISK_WARNING_FREE_MB=1000
APP_HEALTH_DISK_CRITICAL_FREE_MB=250
APP_HEALTH_STATE_PATH=/data/app-health-state.json
HTTP_PORT=80
APP_DATA_DIR=/data
HOST_DATA_DIR=/var/lib/mileage-logger
HOST_BACKUP_DIR=/var/lib/mileage-logger/backups
AUTOMATIC_BACKUPS_ENABLED=true
AUTOMATIC_BACKUP_DIR=/data/backups
AUTOMATIC_BACKUP_RETRY_SECONDS=60
MAX_BACKUP_RESTORE_BYTES=262144000When OWNTRACKS_SYNC_WAYPOINTS=true, published OwnTracks waypoint payloads create or update app
waypoints. Location inregions values are only used to match already-saved waypoints; they do not
create new waypoints.
The web login protects rendered browser pages only. Public unauthenticated browser paths are
limited to /login, passkey login challenge/verify endpoints, root icon and manifest files, the
service worker, and /static/ assets needed to render those pages. Non-OwnTracks /api/ routes
use WEB_API_KEY instead of the web login, while the public web service exposes only the OwnTracks ingestion
endpoints so OwnTracks can continue to use its existing API authentication. Set
WEB_SESSION_COOKIE_SECURE=false only when testing over plain HTTP. The login page does not reveal
the app name before authentication and temporarily locks out repeated failed attempts. Successful
logins, failed login attempts, and lockout rejections are stored as structured PostgreSQL audit
rows. The submitted password value is never stored; failed-login entries record only its length.
Diagnostics resolves successful-login and failed-login rows from trusted forwarded metadata, so the
failed-login block button targets the real browser IP.
Diagnostics has a Configure Passkey card for the single configured web-login user. After creating a
passkey, the login page shows Device Sign-In. Failed passkey assertions are logged and counted
through the same lockout and Cloudflare auto-block path as failed password logins.
Selecting This is a public device disables Device Sign-In and applies a 15-minute inactivity
timeout. Timeout or logout clears the signed session cookie, browser cache, service worker, and
site storage for that public-device session.
When CLOUDFLARE_IP_BLOCKING_ENABLED=true, Diagnostics can create and remove app-managed
Cloudflare zone IP Access Rule blocks using CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID.
CLOUDFLARE_API_TOKEN must be a Cloudflare API token with Account Firewall Access Rules Write
access for the configured zone; do not use CLOUDFLARED_TUNNEL_TOKEN or a Global API Key in that
field.
The app automatically blocks a client IP after CLOUDFLARE_AUTO_BLOCK_FAILED_LOGIN_ATTEMPTS
consecutive failed web-login attempts. A successful login from that IP resets the consecutive
failure count. The Cloudflare blocked-IP card can also send a manually entered valid IP address
with a required reason, then shows the reason with an Auto or Manual source pill in the app-managed
list. Removing a block from the list removes both the Cloudflare rule and the local app-managed
row. Set CLOUDFLARE_IP_BLOCK_ALLOWLIST to comma-separated trusted IPs or CIDRs that
should never be blocked by this app.
Set PUSHOVER_ENABLED=true, PUSHOVER_TOKEN to your Pushover app API token, and PUSHOVER_USER
to your user/group key to receive app-health notifications. PUSHOVER_APP_KEY and
PUSHOVER_USER_KEY are accepted aliases. The app watches database availability and latency,
free disk space, active web-login lockouts, and app-managed Cloudflare blocks. High latency must
remain above its warning or critical threshold for
APP_HEALTH_DB_LATENCY_SUSTAINED_SECONDS before Pushover sends an alert. Disk warning and critical
alerts use APP_HEALTH_DISK_WARNING_FREE_MB and APP_HEALTH_DISK_CRITICAL_FREE_MB, not a disk-used
percentage. It sends one
degraded/unavailable notification when the issue
set changes and one restored notification when all monitored checks are healthy.
The Diagnostics page marks travel when recent OwnTracks movement outside saved waypoints covers at
least OWNTRACKS_TRAVEL_DISTANCE_M meters.
From the server, send a test point:
source .env
python - <<'PY'
import base64
import json
import os
import urllib.request
from datetime import datetime, UTC
from nacl.secret import SecretBox
key = os.environ["OWNTRACKS_ENCRYPTION_KEY"].encode("utf-8").ljust(SecretBox.KEY_SIZE, b"\0")
payload = {
"_type": "location",
"lat": 42.3314,
"lon": -83.0458,
"tst": int(datetime.now(UTC).timestamp()),
"tid": "IP",
"topic": "owntracks/test/phone",
}
encrypted = SecretBox(key).encrypt(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
body = json.dumps({
"_type": "encrypted",
"data": base64.b64encode(bytes(encrypted)).decode("ascii"),
}).encode("utf-8")
request = urllib.request.Request(
f"http://127.0.0.1:{os.environ.get('HTTP_PORT', '80')}/api/owntracks",
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": "Basic " + base64.b64encode(
f"{os.environ['OWNTRACKS_USERNAME']}:{os.environ['OWNTRACKS_PASSWORD']}".encode()
).decode("ascii"),
},
)
print(urllib.request.urlopen(request, timeout=10).status)
PYExpected response:
[]View the newest stored point on Diagnostics:
http://127.0.0.1:${HTTP_PORT:-80}/diagnostics
- Open
http://your-server/. - Add work waypoints in OwnTracks and publish them to the server.
- Go to
Waypointsto review saved waypoints or export an OwnTracks waypoint backup. - Configure OwnTracks to send waypoint transition events and normal location updates.
- Review automatically generated trips from the
Tripspage. - Open
Trips, choose the report month/year, add manual trips, and correct waypoints or miles if needed. - Confirm
VEHICLE_MPGis set correctly and add or fetch the monthly gas price for that month. - Click
Download PDF Reportto generate and download the PDF.
The PDF can be generated for any retained month that has trips and a saved monthly gas price or
daily gas snapshots for that month. The automatic OwnTracks purge removes only processed raw
OwnTracks rows after the retention window and keeps generated trips locked in.
Set REPORT_DISPLAY_NAME in .env when the downloaded PDF should identify the report submitter;
when set, the name appears under the PDF title as Submitted by:.
The PDF report title shows the selected report month as a month name and year, such as
Mileage Log - June 2026.
The PDF summary highlights the final total reimbursement dollar amount with a yellow background.
Reimbursement is calculated as:
total trip miles / VEHICLE_MPG = reimbursement gallons
reimbursement gallons * Michigan monthly average gas price = total reimbursement
PDF reports use a portrait page layout and are generated only when you click Download PDF Report;
they are streamed to the browser and are not saved on the server.
Runtime, request, worker, trip-calculation, and debug logs are written only to container
stdout/stderr. Use docker compose logs -f mlapp for Compose or
docker service logs -f <stack>_mlapp for Swarm. No application log file is created.
Log timestamps are formatted in LOCAL_TIMEZONE, and Docker Compose also sets the container TZ
value from LOCAL_TIMEZONE.
Set LOG_LEVEL to debug, info, or warning. Error log lines are always included.
The app container runs the gas price snapshot scheduler when GAS_SNAPSHOT_ENABLED=true. It uses
the same command that remains available for manual or host-timer runs:
mileage-logger gas-snapshotBy default Docker runs one snapshot on app startup and then every 24 hours.
Relevant .env settings:
GAS_PRICE_SOURCE=aaa_current
GAS_SNAPSHOT_ENABLED=true
GAS_SNAPSHOT_INTERVAL_SECONDS=86400
GAS_SNAPSHOT_RUN_ON_STARTUP=trueView gas snapshot logs with the normal app logs:
docker compose logs -f mlappYou can disable the in-app scheduler with GAS_SNAPSHOT_ENABLED=false and use a host systemd
timer instead of cron. For example, a timer can run
docker compose exec -T mlapp mileage-logger gas-snapshot every 24 hours while the app container
keeps serving requests. Do not try to run systemd inside the app container; the Docker image runs a
single application process.
Optional host service:
# /etc/systemd/system/mileage-logger-gas-snapshot.service
[Unit]
Description=Mileage Logger gas price snapshot
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
WorkingDirectory=/opt/Mileage-Logger
ExecStart=/usr/bin/docker compose exec -T mlapp mileage-logger gas-snapshotOptional host timer:
# /etc/systemd/system/mileage-logger-gas-snapshot.timer
[Unit]
Description=Run Mileage Logger gas price snapshot every 24 hours
[Timer]
OnBootSec=15min
OnUnitActiveSec=24h
Persistent=true
Unit=mileage-logger-gas-snapshot.service
[Install]
WantedBy=timers.targetUse the actual repository path for WorkingDirectory, then enable the timer:
sudo systemctl daemon-reload
sudo systemctl enable --now mileage-logger-gas-snapshot.timerYou can view database-backed web-login audit records from the in-app Diagnostics page and use
Docker's log commands for application output.
Do not expose real location data over plain HTTP on the internet.
Recommended options:
- Put this stack behind an existing reverse proxy that handles Let's Encrypt.
- Use Cloudflare Tunnel, Tailscale Funnel, Caddy, Traefik, or another TLS terminator.
- Extend
deploy/nginxlater to include certificates directly.
If TLS terminates outside this Compose stack, proxy traffic to this stack's HTTP_PORT.
View logs:
docker compose logs -f mlapp
docker compose logs -f mlnginx
docker compose logs -f postgres # only when COMPOSE_PROFILES=local-postgresRestart:
docker compose restartStop:
docker compose downdocker compose down stops and removes containers but keeps named volumes. When
COMPOSE_PROFILES=local-postgres, that includes the named PostgreSQL volume. Do not run
docker compose down -v or Docker volume prune unless you have a verified full backup and intend
to delete local persisted data.
Update from GitHub:
git pull
docker compose up -d --buildWith the default local-postgres profile, normal rebuilds keep database rows because PostgreSQL
stores data in the named Docker volume postgres_data mounted at /var/lib/postgresql/data. In
Portainer, keep the same stack name when redeploying; changing the Compose project or stack name
can make Docker create a different postgres_data volume and look like a fresh install. Remote
PostgreSQL deployments should back up and maintain the database on the central database server.
The Diagnostics page includes authenticated full app data backup and restore controls. Use
Download Full Backup before updates or database work. The downloaded .json.gz file contains all
Mileage Logger app tables plus an OwnTracks waypoint export. To restore it, open Diagnostics,
upload the file, and type RESTORE; the app validates the backup before replacing current app
table rows in one transaction. Backup files contain sensitive location history and should be stored
securely.
The app also creates one automatic startup full-data backup and then 6-hour full-data backups by
default. In Docker they are stored under /data/backups, backed by the dedicated
HOST_BACKUP_DIR on the host, unless AUTOMATIC_BACKUP_DIR is set to another private path.
Storage failures, including stale file handles, pause backup creation and retry every
AUTOMATIC_BACKUP_RETRY_SECONDS until a backup succeeds. Diagnostics labels startup
backups, lists retained automatic backups, and can restore a selected file after you type
RESTORE. Retention keeps the newest 4 recent automatic backups plus one daily backup for each of
the prior 2 days.
Back up the bundled PostgreSQL container:
docker compose exec -T postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > mileage_logger.sqlThis command only applies when COMPOSE_PROFILES=local-postgres. For remote PostgreSQL,
run pg_dump or your preferred backup process on the central database server. The in-app backup is
the preferred quick recovery file for this application. pg_dump remains useful for low-level
PostgreSQL administration or migration outside the app.
Volume names may differ if your Compose project name is not mileage-logger. Check with:
docker volume ls | grep mileageCheck container health:
docker compose psCheck app startup and migration logs:
docker compose logs mlappValidate the web service proxy:
curl -i "http://127.0.0.1:${HTTP_PORT:-80}/"
curl -i "http://127.0.0.1:${HTTP_PORT:-80}/api/health" # Expected public result: 404If OwnTracks returns unauthorized, confirm .env values and restart:
grep OWNTRACKS .env
grep WEB_API_KEY .env
docker compose restart mlappIf ports conflict, change HTTP_PORT in .env:
HTTP_PORT=8080Then restart:
docker compose up -dIf the web UI returns 403 Forbidden, your client IP does not match WEB_ALLOWED_CIDRS.
OwnTracks ingestion endpoints should still be reachable; other public /api/ routes are
intentionally blocked by the web service.
If app logs show Could not parse SQLAlchemy URL from given URL string, the DATABASE_URL value
is malformed. Check that it uses the SQLAlchemy PostgreSQL form and that the password is
URL-encoded:
DATABASE_URL=postgresql+psycopg://db_user:url_encoded_password@db-host:5432/database_nameYou can encode a password without printing any other secrets:
python3 -c 'from urllib.parse import quote; import getpass; print(quote(getpass.getpass(), safe=""))'