Track public Mastodon accounts and analyze how their posts perform over time.
MastoLens is a self-hosted Laravel web application that lets you follow public Mastodon accounts and watch their engagement metrics evolve — favourites, boosts, and replies — captured at intelligent intervals over 30 days. No Mastodon API keys required. No data leaves your server.
Mastodon doesn't provide analytics for accounts you don't own. If you're a researcher, community manager, or just curious about how content performs in the fediverse, there's no built-in way to track engagement over time.
MastoLens solves this by:
- Polling public Mastodon API endpoints (no authentication with Mastodon needed)
- Storing engagement snapshots at strategic intervals
- Building historical growth curves for every tracked post
- Running entirely on your own infrastructure — no third-party services, no CDNs, no tracking
- Track any public Mastodon account — enter
user@instance.tldand MastoLens resolves it via WebFinger + the instance API - Discover posts automatically — fetches the latest 20 public statuses per tracked account every 5 minutes
- Snapshot engagement metrics — captures favourites, boosts, and reply counts on an intelligent schedule (see below)
- Classify post types — originals, replies, boosts (with full original post content and author info)
- Account averages (dashboard/account page) — average favourites, boosts, and replies are calculated across all tracked time, using original posts only (replies/boosts excluded)
- Visualize growth — per-post engagement timeline charts (Chart.js, fully self-hosted)
- Track followers — daily follower count snapshots with trend charts
- Archive gracefully — after 30 days, polling stops but all data remains visible
- Multi-user — each user manages their own tracked accounts with private data
This is the core of MastoLens. Rather than polling every post every minute (which would hammer the API), MastoLens uses an intelligent age-based snapshot schedule.
Each discovered post gets snapshots at these ages:
| Age | Interval |
|---|---|
| 0 min | Captured inline at discovery |
| 15 min | First follow-up |
| 30 min | |
| 1 hour | |
| 2 hours | |
| 4 hours | |
| 8 hours | |
| 12 hours | |
| 24 hours | |
| 2 days | |
| 3 days | |
| 7 days | |
| 14 days | |
| 30 days | Final snapshot, then archived |
Chart note: On the status detail chart, MastoLens shows a synthetic Posted baseline at zero engagement (0,0,0) so the curve starts at publication. The first real data point is the discovery snapshot (labeled Initial in the UI), which may be a few minutes after publish depending on the 5-minute sync cycle.
-
Discovery — Every 5 minutes, MastoLens fetches the latest 20 statuses for each tracked account. New posts are stored with an initial real snapshot taken inline at discovery time.
-
Scheduling — Each post has a
next_snapshot_due_attimestamp. After discovery, this is set to the next target age relative to the post creation time. -
Capture — Every minute, the scheduler checks: "Are there any posts whose
next_snapshot_due_athas passed?" If yes, it dispatches a job that:- Fetches current metrics from the Mastodon API
- Stores the snapshot
- Sets
next_snapshot_due_atto the next target time
-
Between targets — The post is simply skipped. No API calls, no wasted resources.
-
Archive — After the 30-day snapshot,
next_snapshot_due_atis set tonull. The post is archived — no more API calls, ever. But all historical data remains visible.
Example walkthrough: A post is published at 3:03 PM. The next account sync runs at 3:05 PM, discovers it, and stores the first real snapshot (Initial in the chart).
The chart also includes a synthetic Posted baseline at 3:03 PM with zero engagement so growth is visually anchored to publication time. Because 15 minutes after publish is 3:18 PM, next_snapshot_due_at is set to 3:18 PM. At 3:18, the scheduler captures metrics and advances to the next target (3:33, 4:03, 5:03, etc.) until 30 days.
Over the entire lifetime of tracking, only 14 API calls are made per post.
- Docker and Docker Compose
- Git
That's it. Everything else runs inside containers.
git clone <your-repo-url> mastolens
cd mastolenscp .env.example .envEdit .env and set at minimum:
APP_PORT=8080 # Port the app will be available on
DB_PASSWORD=secret # Change this for production
DB_ROOT_PASSWORD=rootsecret # Change this for productiondocker compose up -d --buildThis is required — Laravel needs an encryption key for sessions, cookies, and CSRF tokens. Without it, the app will not function.
First, generate and display a key:
docker compose exec app php artisan key:generate --showThis outputs a key like base64:AbCdEf.... Copy it into your .env file:
APP_KEY=base64:AbCdEf...Then rebuild so the container picks up the key:
docker compose up -d --build app webImportant: Save this key. If you lose it, active sessions become invalid and any encrypted data becomes unreadable. When migrating to a new server, always bring your
.envfile (which contains this key) along withdocker/data/.
This builds and starts 5 containers:
| Container | Role |
|---|---|
mastolens-app |
PHP-FPM application server (runs migrations on startup) |
mastolens-web |
Nginx reverse proxy (serves static assets + proxies to app) |
mastolens-db |
MariaDB 11 database |
mastolens-worker |
Queue worker (processes sync, snapshot, and archive jobs) |
mastolens-scheduler |
Cron-based scheduler (triggers Laravel's task scheduler every minute) |
Open http://localhost:8080 (or whatever APP_PORT you set).
Register a new account, log in, and start tracking Mastodon accounts.
- Go to Tracked Accounts → Add Account
- Enter a handle like
user@mastodon.social - MastoLens resolves the account, fetches their latest posts, and begins tracking automatically
| Variable | Default | Description |
|---|---|---|
APP_PORT |
8080 |
Host port for the web interface |
DB_PASSWORD |
secret |
MariaDB user password — change in production |
DB_ROOT_PASSWORD |
rootsecret |
MariaDB root password — change in production |
APP_DEBUG |
true |
Set to false in production |
APP_ENV |
local |
Set to production in production |
REGISTRATION_ENABLED |
true |
Set to false to disable public user registration |
| Variable | Default | Description |
|---|---|---|
APP_DOMAIN |
localhost |
Your FQDN (e.g., stats.example.com) |
APP_URL |
http://localhost:8080 |
Full base URL of the application |
SSL_ENABLED |
false |
Enable HTTPS in Nginx |
SSL_PORT |
8443 |
Host port for HTTPS |
SSL_CERT_PATH |
/etc/nginx/ssl/cert.pem |
Path to SSL certificate inside container |
SSL_KEY_PATH |
/etc/nginx/ssl/key.pem |
Path to SSL key inside container |
| Variable | Default | Description |
|---|---|---|
DB_DATABASE |
mastolens |
Database name |
DB_USERNAME |
mastolens |
Database user |
DB_HOST |
db |
Database host (the Docker service name) |
| Variable | Default | Description |
|---|---|---|
ALLOW_FRESH_MIGRATE |
true |
Safety guard: if the DB appears empty but has many pending migrations, the entrypoint will refuse to run unless this is true. Prevents accidental data loss if a volume is wiped. |
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Browser │────▶│ Nginx │────▶│ PHP-FPM │
│ │ │ (web) │ │ (app) │
└─────────────┘ └─────────────┘ └──────┬──────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ MariaDB │ │ Worker │ │ Scheduler │
│ (db) │ │ (queue) │ │ (cron) │
└───────────┘ └───────────┘ └───────────┘
- 4-stage Dockerfile: Frontend assets (Node/Vite) → PHP dependencies (Composer) → App image (PHP-FPM) → Web image (Nginx)
- No external services: Queue, cache, and sessions all use the database. No Redis, no external APIs beyond Mastodon instances.
- All assets self-hosted: Chart.js is bundled via Vite. No CDN calls.
All persistent data is stored in ./docker/data/:
| Path | Contents |
|---|---|
docker/data/mariadb/ |
MariaDB data files |
docker/data/storage/ |
Laravel storage (logs, cache, etc.) |
These directories are Docker volume mounts. Your data survives container rebuilds. To start completely fresh, stop containers and delete docker/data/.
docker compose up -d --build app webAlways rebuild app and web together to keep Vite asset hashes in sync.
# Application logs
docker compose exec app cat storage/logs/laravel.log | tail -50
# Worker output
docker compose logs worker --tail 50
# Scheduler output
docker compose logs scheduler --tail 50docker compose exec app php artisan tinker --execute="
App\Jobs\SyncTrackedAccountStatusesJob::dispatch(
App\Models\TrackedAccount::find(1)
);
"docker compose psdocker compose downdocker compose down
rm -rf docker/data/mariadb
docker compose up -d --build| Component | Technology |
|---|---|
| Backend | Laravel (PHP 8.4) |
| Database | MariaDB 11 |
| Queue | Laravel database driver |
| Scheduler | Cron → php artisan schedule:run |
| Frontend | Blade templates, Tailwind CSS |
| Charts | Chart.js (bundled via Vite) |
| Auth | Laravel Fortify |
| Containers | Docker (4-stage multi-stage build) |
- The app is designed for private, authenticated use. All routes require login.
- Mastodon data is fetched from public API endpoints only — no OAuth tokens or API keys needed.
- The
ALLOW_FRESH_MIGRATEsafety check prevents accidental data wipes if the database volume is lost. - Set
APP_DEBUG=falseandAPP_ENV=productionbefore exposing to the internet. - Change default database passwords in
.envfor any non-local deployment. - For HTTPS setup, see the production deployment section below.
To deploy MastoLens on a real server with a domain (e.g., mastolens.example.tld):
1. Update .env:
APP_NAME=MastoLens
APP_ENV=production
APP_DEBUG=false
APP_URL=https://mastolens.example.tld
APP_DOMAIN=mastolens.example.tld
APP_PORT=80
SSL_ENABLED=true
SSL_PORT=443
DB_PASSWORD=use-a-strong-password-here
DB_ROOT_PASSWORD=use-a-different-strong-password-here2. Obtain SSL certificates and configure:
MastoLens needs SSL certificate files to serve HTTPS. Choose one of these approaches:
Option A: Certbot on the host (simplest)
# Install certbot (Ubuntu/Debian)
apt install certbot
# Stop MastoLens so port 80 is free for the challenge
docker compose down
# Obtain certificate
certbot certonly --standalone -d mastolens.example.tld
# Copy certs into the project
mkdir -p docker/ssl
cp /etc/letsencrypt/live/mastolens.example.tld/fullchain.pem docker/ssl/cert.pem
cp /etc/letsencrypt/live/mastolens.example.tld/privkey.pem docker/ssl/key.pemThen uncomment the SSL volume mount in docker-compose.yml:
web:
volumes:
- ./docker/data/storage/app/public:/var/www/html/public/storage:ro
- ./docker/ssl:/etc/nginx/ssl:ro # ← uncomment this lineLet's Encrypt certs expire every 90 days. Set up a cron job on the host to renew:
# Add to root's crontab (crontab -e)
0 3 1 */2 * cd /path/to/mastolens && docker compose stop web && certbot renew --quiet && cp /etc/letsencrypt/live/mastolens.example.tld/fullchain.pem docker/ssl/cert.pem && cp /etc/letsencrypt/live/mastolens.example.tld/privkey.pem docker/ssl/key.pem && docker compose up -d webOption B: Reverse proxy with automatic SSL (Caddy)
If you'd rather not manage certificates at all, place Caddy in front of MastoLens. Caddy obtains and renews Let's Encrypt certificates automatically with zero configuration:
# Install Caddy on the host, then create a Caddyfile:
mastolens.example.tld {
reverse_proxy localhost:8080
}In this setup, keep SSL_ENABLED=false and APP_PORT=8080 in your .env — Caddy handles all SSL. No docker/ssl/ directory or volume mount needed.
3. Build and start:
docker compose up -d --buildHow the ports work:
| Setting | What happens |
|---|---|
APP_PORT=80 |
Host port 80 → Nginx port 80 inside container |
SSL_PORT=443 |
Host port 443 → Nginx port 443 inside container |
SSL_ENABLED=true |
Nginx uses the HTTPS config: serves SSL on 443, redirects HTTP→HTTPS on 80 |
SSL_ENABLED=false |
Nginx serves plain HTTP on port 80 only |
When SSL_ENABLED=true, visitors hitting http://mastolens.example.tld are automatically redirected to https://mastolens.example.tld. You don't need a separate reverse proxy — Nginx inside the container handles both SSL termination and the HTTP→HTTPS redirect.
For local development, the defaults (APP_PORT=8080, SSL_ENABLED=false) serve the app at http://localhost:8080 with no SSL.
Note: If your server already runs a reverse proxy (e.g., Traefik, Caddy, or another Nginx), you can keep
SSL_ENABLED=falseand let the outer proxy handle SSL. In that case, setAPP_PORTto an internal port (e.g.,8080) and proxy to it.
By default, MastoLens logs all emails to storage/logs/laravel.log instead of sending them. This is fine for local development, but for production you'll want real email delivery (for password resets and other notifications).
SMTP provider (e.g., Mailgun, Postmark, Amazon SES, or any SMTP server):
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailgun.org # Your SMTP host
MAIL_PORT=587 # Usually 587 (TLS) or 465 (SSL)
MAIL_USERNAME=your-smtp-user
MAIL_PASSWORD=your-smtp-password
MAIL_ENCRYPTION=tls # tls or ssl
MAIL_FROM_ADDRESS=noreply@mastolens.example.tld
MAIL_FROM_NAME="${APP_NAME}"Common providers:
| Provider | MAIL_HOST | MAIL_PORT | Notes |
|---|---|---|---|
| Mailgun | smtp.mailgun.org |
587 |
Free tier: 1,000 emails/month |
| Postmark | smtp.postmarkapp.com |
587 |
Free tier: 100 emails/month |
| Amazon SES | email-smtp.us-east-1.amazonaws.com |
587 |
Very cheap at scale |
| Gmail | smtp.gmail.com |
587 |
Use App Passwords, not your real password |
| Generic SMTP | Your server's SMTP host | 587 |
Works with any standard SMTP server |
After changing mail settings, rebuild the app container to pick up the new config:
docker compose up -d --build appTip: To test your mail configuration, register a new user account — the password reset flow will send an email.
Once you've created your account(s), you can lock down the app so nobody else can register. Add this to your .env:
REGISTRATION_ENABLED=falseRebuild to apply:
docker compose up -d --build app workerThe /register route will return a 404. Login, password reset, and all other features continue to work normally.
If registration is disabled (or you simply prefer not to use the web form), you can create users via the command line:
docker compose exec app php artisan tinker --execute="
App\Models\User::create([
'name' => 'Your Name',
'email' => 'you@example.com',
'password' => Hash::make('YourSecurePassword123!'),
]);
"To reset a user's password:
docker compose exec app php artisan tinker --execute="
\$user = App\Models\User::where('email', 'you@example.com')->first();
\$user->update(['password' => Hash::make('NewPassword123!')]);
"Tests use PHPUnit with an in-memory SQLite database — no MariaDB required. Run them alongside your existing containers:
docker compose run --rm --no-deps \
-v "$PWD/tests:/var/www/html/tests" \
app php artisan testThe tests/ directory is excluded from Docker images (via .dockerignore) to keep production builds lean. The -v flag mounts it into a throwaway container for the test run.
This project is private software. All rights reserved.