Tidemark

Tidemark

Docker app from FoggyMtnDrifter's Repository

Overview

Tidemark is privacy-first web analytics with one calm dashboard. It sets no cookies and never stores IP addresses, and visitor ids change every day, so nobody can be followed around. It shows visitors, pageviews, bounce rate and visit duration, with sources, pages, locations, devices, goals and custom events underneath, plus public dashboards and password-protected shared links you can brand with your own logo and color. The tracking script is about 2 KB and follows Plausible's conventions.

First run: start the container, open the WebUI, and visit /install to create your admin account (SQLite needs no setup; MySQL, MariaDB and PostgreSQL work too). Everything lives in the appdata folder. Put it behind a reverse proxy for HTTPS and set Public address to match. Update by pulling a newer image; migrations run on the first visit.

Questions? Join the community on Discord: https://discord.gg/sCkHD3PPZh

Tidemark Analytics

CI Latest release License: MIT Community

As seen on Open Source Alternatives Tidemark Analytics | AlternativeTo

The Tidemark Analytics dashboard: a summary, KPI tiles, a graph and reports for traffic, audience and conversions

Tidemark Analytics is simple, privacy-first web analytics that runs on ordinary PHP shared hosting. One light dashboard in the spirit of Plausible, built with Laravel — upload a zip, open /install, done. No Node, no Docker, no SSH required.

  • Cookie-free, no personal data: visitor ids are a salted hash that rotates every day, and IP addresses are never written to the database
  • One page, everything on it: visitors, visits, pageviews, views per visit, bounce rate and visit duration, with sources, pages, locations, devices and goals underneath
  • Plausible-compatible tracking script and Events API (data-domain, window.plausible(...), script.outbound-links.js-style variants)
  • Import your history from Plausible's CSV export
  • Works on SQLite, MySQL/MariaDB and PostgreSQL
  • Designed to scale out later: swappable event sink, separate analytics database connection, stateless app tier

Don't want to run it yourself? Tidemark Cloud

The same open-source Tidemark Analytics, hosted for you at tidemarkanalytics.com: no server, no updates, nothing to install. Sign up, paste one line into your site's <head>, and your dashboard is ready.

  • Free to start: 1 site, 10,000 events a month and 6 months of history, with every report, filter, goal and shared link.
  • Pro, $7 a month ($70 a year): unlimited sites, 250,000 events a month, 5 years of history, funnels, revenue, segments, journeys, rollups, alerts and webhooks, and your own logo and color on public dashboards. Need more events? Add packs of 250,000 for $5 whenever you like.
  • Still private by design: no cookies and no stored IP addresses, and direct support from inside the app.
  • Switching? Import your history from Plausible's CSV export.

Try the live demo · See pricing · Start free

Prefer to host it yourself? Everything below is for you, and it is free under the MIT license.

Install on shared hosting (no SSH)

  1. Download the latest tidemark-x.y.z.zip from Releases (dependencies are bundled), or build it yourself: scripts/build-release.sh 0.8.0 → dist/tidemark-0.8.0.zip.

  2. Upload and unzip it on your host. Point the domain's document root at the public/ folder. If your host won't let you, upload to the web root as-is — the bundled root .htaccess forwards to public/ and blocks internals.

  3. Visit https://your-domain/install: the page checks the server, connects your database (SQLite needs no setup), runs the migrations and creates your admin account.

  4. Add a cron job (cPanel → Cron Jobs) — only needed for queue ingestion and data retention:

    * * * * * php /home/you/tidemark/artisan schedule:run >> /dev/null 2>&1
    
  5. Add a site and paste the snippet into your site's <head>. The dashboard waits for the first pageview and then opens by itself.

Requirements: PHP 8.3+, mbstring, openssl, pdo + one of pdo_sqlite / pdo_mysql / pdo_pgsql, fileinfo, ctype, tokenizer, xml.

Install with Docker

A pre-built image for amd64 and arm64 is published to ghcr.io/tidemark-apps/analytics. With the docker-compose.yml in this repository:

docker compose up -d

then open http://localhost:8080/install to create your admin account. Everything that changes lives in the /data volume (settings, the SQLite database, logs), the scheduler runs inside the container, and you update by pulling a newer image (docker compose pull && docker compose up -d); migrations run on the first request. Set PUID/PGID to run as a different user (Unraid uses 99/100). MySQL, MariaDB and PostgreSQL work too: choose them in the installer and point them at your database container.

Local development

composer install && cp .env.example .env && php artisan key:generate
touch database/database.sqlite && php artisan migrate
php artisan tidemark:demo        # fake traffic for demo.example.com; login demo@example.com / password
php artisan serve
php artisan test

Updating

Sign in as an administrator, open Account settings → Updates and Tidemark shows when a newer release exists on GitHub; Download and install fetches it, checks its SHA-256 checksum and installs it. Tidemark looks for new releases daily, and every few hours while an administrator is using the app, and shows a dot by your profile when one is out; only the latest version number is requested. Set TIDEMARK_UPDATE_CHECK=false to turn that off, or TIDEMARK_UPDATE_REPO to follow a fork. You can also upload a release zip (tidemark-x.y.z.zip) yourself. Either way Tidemark validates the zip, backs up the current code to storage/app/updates/backups/, replaces the application files (never .env, storage/ or your database), then runs the database migrations on the next request. The upload is sent in small pieces, so low PHP upload limits don't matter. It needs PHP's zip extension and a folder PHP can write to; otherwise upload the release over FTP as usual and visit the site once to run migrations. scripts/build-release.sh <version> stamps the version into the zip.

Tracking

<script defer data-domain="example.com" src="https://your-tidemark.example/js/script.js"></script>

Pageviews are sent on load and on every history change, so single-page apps work without extra code. Optional behavior is picked by the file name — combine as many as you like (script.outbound-links.file-downloads.js):

Variant Adds
outbound-links Outbound Link: Click events (url property)
file-downloads File Download events (url property)
form-submissions Form: Submission events
engagement one Engagement event per page view with how far down the page the visitor scrolled and how long they were active (details)
tagged-events click events from class="plausible-event-name=Signup"
hash pageviews on #hash route changes
manual no automatic pageviews; call plausible('pageview') yourself
local also track localhost

Site settings → General picks the right file name for you. Custom events and properties:

plausible('Signup', { props: { plan: 'pro' }, callback: () => {} })

Create a matching goal (custom event or page) in the site's settings to see it under Behaviors. Use data-api="https://…/api/event" to send through a proxy on your own domain. Anything that speaks Plausible's Events API works too: POST /api/event with {"n": "pageview", "u": "https://example.com/", "d": "example.com", "r": null, "w": 1280, "p": {…}}.

Scroll depth and time on page

The engagement variant (Site settings → Tracking → Scroll depth and time on page, or add .engagement to the file name) answers "did they read it?" with numbers only: no element, text or position is ever captured. When a visitor leaves a page or switches tab, the script sends one Engagement event with two properties:

Property Values
depth the furthest milestone reached: 0, 25, 50, 75 or 100 (percent of the page). A page that fits on one screen is 100.
time active time on the page: 0-10s, 10-30s, 30-60s, 1-3m or 3m+

Add an Engagement goal (turning the setting on creates it), then open it under Behaviors to see how many visitors reached each depth and time, or filter any report by those properties, in goals and funnels too. Good to know:

  • The event is not an interaction, so bounce rate and visit duration don't change, and it is stored as one row per page view however often the visitor comes and goes (the latest totals win). It does count as one extra event per page view.
  • Active time stops after a minute without scrolling, moving the mouse, typing or touching the screen, and only counts while the tab is visible.
  • Single-page apps are measured per route change. A measurement that arrives more than 30 minutes after the visit's last hit is dropped rather than starting a visit of its own.
  • Send your own with plausible('Name', { interactive: false }) or "i": 0 in the Events API.

What's included

  • Dashboard — the six headline metrics with comparison (previous period or year over year), a graph with hour/day/week/month intervals, and a view as table twin.
  • Reports — Sources (channels, sources, UTM campaigns), Pages (top, entry, exit), Locations (world map, countries, regions, cities), Devices (browsers, operating systems, device types), Behaviors (goals, custom properties). Every list has a Details view with search and paging.
  • Filters — click any row, or use Filter: is, is not, contains, does not contain, wildcards (/blog/*), several values per filter. Filtering by a goal switches the dashboard into conversion mode.
  • Periods — today, realtime, 24 hours, 7/28/30/91 days, month, 6/12 months, year, all time, custom; keyboard shortcuts for all of them (D R W T Q M S L Y A C, X compare, F filter, ←/→ step through days, months or years).
  • Sharing — make a dashboard public, or create private shared links with an optional password.
  • Goals, shields, people — conversion goals, block rules (IP, country, page, hostname allow-list), owner/admin/viewer roles per site, teams (group people once and share a site with the whole team as viewers or admins) and organizations (everyone has a Personal one; create others with an owner, admins and teams, and hand one over by transferring ownership).
  • CSV export, a Stats API, API keys, and two-factor sign-in with an authenticator app, email codes or passkeys.

Brand

Logo (pictorial mark: pier pilings with a tide line across them), palette, typography, voice, naming rules and marketing copy are documented in resources/brand/index.html (open it through any static server, e.g. python3 -m http.server from the project root, then visit /resources/brand/). The logo set and contour motif are generated by scripts/build-brand.py; PNG/favicon exports by scripts/export-brand-png.sh. Source of truth for colors is public/assets/app.css.

Interface notes

  • Layout: a sidebar lists your sites and the sections of the open dashboard (Overview, Traffic, Audience, Conversions) and opens a ⌘K command palette; one dashboard per site at /{domain}. It opens with a plain-language summary and six KPI tiles, with a sticky toolbar for current visitors, Filter, the date picker and a ⋮ menu (interval, CSV export, settings). Narrow screens use a top bar instead of the sidebar.
  • Charts: hairline grids, one tooltip with the comparison series, keyboard control (←/→) and a View as table twin. Refetching dims the previous render instead of flashing skeletons.
  • Colors: deep-water neutrals with Tide teal as the interface color and Buoy amber as the signal. Chart series (--series-1 Lagoon teal, --series-2 amber) pass the palette validator on both themes — light #0891a0/#d97706, dark #1fa3a8/#cf7f0c. Re-validate if you change them.
  • Accessibility: visible focus rings, focus-trapped dialogs, labeled controls, prefers-reduced-motion and forced-colors support, light/dark/system themes.
  • Fonts: Inter is self-hosted (public/assets/fonts, SIL OFL) — no third-party requests.

Configuration (.env)

Variable Default Notes
TIDEMARK_REGISTRATION false Allow sign-ups
TIDEMARK_INVITES false Let site owners invite people who have no account by e-mail (needs working MAIL_* settings). Invitations expire after TIDEMARK_INVITE_HOURS (default 72)
TIDEMARK_INGEST sync queue buffers writes; drained by schedule:run or a worker
TIDEMARK_ANALYTICS_DB (app DB) Separate connection for visits, events and event_props
TIDEMARK_IGNORE_IP, TIDEMARK_HOSTNAMES – Comma-separated lists
TIDEMARK_RETENTION_DAYS 0 Delete raw data older than N days (daily via scheduler)
TIDEMARK_GEOIP_DATABASE storage/app/geo/GeoLite2-City.mmdb Needs composer require geoip2/geoip2; Cloudflare/Vercel/CloudFront geo headers work with no setup
TIDEMARK_MAX_SITES 0 Sites per user. 0 is unlimited
TIDEMARK_UPDATER true false hides Account settings → Updates and disables its endpoints
TIDEMARK_TERMS_URL, TIDEMARK_PRIVACY_URL, TIDEMARK_REFUNDS_URL – Links to your legal pages, shown on the sign-in and sign-up screens
TIDEMARK_VERIFY_EMAIL false New accounts must confirm their email before adding sites (needs MAIL_* settings)

Behind a CDN (Cloudflare etc.) the real client IP and country are read from its headers automatically.

Stats API

Create a key under Account settings → API keys and send it as Authorization: Bearer …. site_id is the site's domain; the query format follows Plausible's Stats API v1.

GET /api/v1/stats/realtime/visitors?site_id=example.com
GET /api/v1/stats/aggregate?site_id=example.com&period=30d&metrics=visitors,pageviews,bounce_rate
GET /api/v1/stats/timeseries?site_id=example.com&period=30d&metrics=visitors
GET /api/v1/stats/breakdown?site_id=example.com&period=30d&property=visit:source&limit=10

Filters use filters=visit:country==DE;event:page==/blog/*. The dashboard's own JSON endpoints live under /api/sites/{domain}/stats/….

Architecture

 browser ──► POST /api/event ──► Collector ──► EventSink ──► analytics DB (visits, events, event_props)
                                    │           ├─ DatabaseSink  (default: write in-request, stitches visits)
                                    │           └─ QueueSink     (buffer → worker → DatabaseSink)
 dashboard (Preact, no build) ──► JSON API ──► Stats engine (Period + Filters) ──► analytics DB
                                                └─ metadata DB (users, sites, goals, shared links, shields)
  • Visits, not sessions. A visit is one row, stitched in the sink: hits from the same visitor within 30 minutes of inactivity extend it (entry/exit page, pageviews, bounce, duration). Reports read visits for most numbers and events for pageviews and goals.
  • Two logical databases. Hot, append-only tables go through TIDEMARK_ANALYTICS_DB, everything else through the default connection. Tables are keyed by site_id and indexed on time, so they partition and shard naturally.
  • Stateless app tier. Sessions and cache can move to Redis/DB; any number of PHP workers can serve requests.
  • Swappable seams. EventSink (writes) and Stats\Stats (reads) are the only places that know about the storage engine — a ClickHouse implementation can replace them without touching controllers or the UI.
  • Privacy by construction. Visitor ids are hash(site, ip, user-agent, daily salt); raw IPs and user agents are never stored, and because the salt changes every day a visitor cannot be followed from one day to the next.

Project name

Tidemark — a tidemark is the line a tide leaves behind on a shore or harbor wall: a record of how high the water reached, kept without keeping the water. That is the product's privacy model in one image: it keeps the aggregate line and never the footprints of individual visitors.

Icons

Icons are Lucide (ISC license), served from one self-hosted sprite at public/assets/icons.svg (only the icons in scripts/icons.txt). To add one: add its Lucide name to scripts/icons.txt, then npm i --no-save lucide-static && node scripts/build-icons.mjs. Use it with <${Icon} name="chart-column" />.

License

MIT

Community

Questions, ideas and show-and-tell: join the Tidemark community. Bugs belong in GitHub issues.

Contributing

See CONTRIBUTING.md. Security issues: SECURITY.md. Releases are built by GitHub Actions when a vX.Y.Z tag is pushed (the tag must match the VERSION file).

Media gallery

1 / 10

Requirements

A reverse proxy (Nginx Proxy Manager, SWAG, Traefik or a Cloudflare Tunnel) for HTTPS. Visitor countries need a CDN such as Cloudflare in front.

Related apps

Details

Repository
ghcr.io/tidemark-analytics/tidemark:latest
Last Updated2026-11-09
First Seen2026-10-23

Runtime arguments

Web UI
http://[IP]:[PORT:8080]
Network
bridge
Shell
sh
Privileged
false

Template configuration

WebUI portPorttcp

Port for the Tidemark dashboard and the tracking script.

Target
8080
Default
8080
Value
8080
AppdataPathrw

Settings, the SQLite database, logs and uploads. Back this folder up.

Target
/data
Default
/mnt/user/appdata/tidemark
Value
/mnt/user/appdata/tidemark
Public addressVariable

The address people and your websites use to reach Tidemark, such as https://stats.example.com. It appears in the tracking snippet and in links.

Target
APP_URL
Default
http://tower:8080
Value
http://tower:8080
PUIDVariable

User id the app runs as. Unraid's nobody is 99.

Default
99
Value
99
PGIDVariable

Group id the app runs as. Unraid's users is 100.

Default
100
Value
100
Keep raw data (days)Variable

Delete raw visits and events older than this many days. 0 keeps everything.

Target
TIDEMARK_RETENTION_DAYS
Default
0
Value
0
Mail driverVariable

Use smtp to send password reset and invitation emails. The default only writes them to the log.

Target
MAIL_MAILER
Default
log
Value
log
SMTP hostVariable

SMTP server, when the mail driver is smtp.

Target
MAIL_HOST
SMTP portVariable

SMTP port.

Target
MAIL_PORT
Default
587
Value
587
SMTP usernameVariable

SMTP username.

Target
MAIL_USERNAME
SMTP passwordVariable

SMTP password.

Target
MAIL_PASSWORD
Mail from addressVariable

The address emails are sent from.

Target
MAIL_FROM_ADDRESS