Production-ready Docker Compose deployment for FeedLand https://rmendes.net/content/articles/2026-02-17-how-to-self-host-feedland/
  • Shell 97.9%
  • HTML 2.1%
Find a file
Ricardo 31df401b10 Require basic auth for the MailPit UI (magic links were world-readable)
FeedLand's sign-in is a passwordless magic link sent by email. When MailPit
is the configured SMTP host (the default), every user's sign-in link lands
in its catcher. The Caddyfile exposed MailPit's web UI and API at /mail with
no authentication, so anyone who could reach a publicly-deployed instance
could open /mail (or GET /mail/api/v1/messages), read another user's
sign-in link, and sign in as them — unauthenticated full account takeover.

Fix: put Caddy's basic_auth directive in front of the /mail handle, backed
by MAILPIT_USER / MAILPIT_PASSWORD / MAILPIT_PASSWORD_HASH in .env.
scripts/generate-env.sh now generates a random password and its bcrypt hash
(via `caddy hash-password`) automatically and prints the credentials once.

Docker Compose interpolates $name-shaped substrings in .env values, and
bcrypt hashes are full of $-delimited fields, so the hash is written with
every '$' doubled to '$$' (compose un-escapes it back to a literal '$' for
the container) — otherwise the hash is silently truncated and auth breaks
with no obvious cause. Verified with `docker compose exec caddy printenv
MAILPIT_PASSWORD_HASH` in a throwaway test copy, which returned the exact,
unmangled hash.

Upgrade step for existing deployments: add MAILPIT_USER, MAILPIT_PASSWORD,
and MAILPIT_PASSWORD_HASH to your existing .env before `docker compose up
-d` — compose now refuses to start the caddy service without the hash.
Hash a chosen password with:
  docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword'
and remember to double every '$' when pasting the hash into .env. Full
instructions are in the new README section "Securing the Mailpit UI (/mail)".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:05:28 +02:00
db Production-ready FeedLand Docker Compose deployment 2026-02-17 18:10:37 +01:00
scripts Require basic auth for the MailPit UI (magic links were world-readable) 2026-07-13 17:05:28 +02:00
.dockerignore Production-ready FeedLand Docker Compose deployment 2026-02-17 18:10:37 +01:00
.env.example Require basic auth for the MailPit UI (magic links were world-readable) 2026-07-13 17:05:28 +02:00
.gitignore Production-ready FeedLand Docker Compose deployment 2026-02-17 18:10:37 +01:00
Caddyfile Require basic auth for the MailPit UI (magic links were world-readable) 2026-07-13 17:05:28 +02:00
CLAUDE.md Require basic auth for the MailPit UI (magic links were world-readable) 2026-07-13 17:05:28 +02:00
docker-compose.yml Require basic auth for the MailPit UI (magic links were world-readable) 2026-07-13 17:05:28 +02:00
emailtemplate.html Production-ready FeedLand Docker Compose deployment 2026-02-17 18:10:37 +01:00
entrypoint.sh Production-ready FeedLand Docker Compose deployment 2026-02-17 18:10:37 +01:00
README.md Require basic auth for the MailPit UI (magic links were world-readable) 2026-07-13 17:05:28 +02:00

FeedLand Docker

Production-ready Docker Compose deployment for FeedLand by Dave Winer.

Four containers: FeedLand (Node.js app), MySQL 8.0 (database), Caddy (reverse proxy with automatic HTTPS), Mailpit (built-in mail catcher).

Why This Repo

This project builds on scotthansonde/feedlandDockerCompose, the original Docker Compose setup for FeedLand, and on the Caddy configuration from Scott Hanson's HTTPS guide in the FeedLand install repo. The two setups have different goals: the original is a lean, dev-friendly way to get an instance up quickly, while this repo is tuned for running an always-on instance unattended. The differences:

Concern feedlandDockerCompose feedland-docker (this repo)
Config + schema setup Helper containers render config and fetch setup.sql from GitHub at startup Single entrypoint generates and validates config.json; schema vendored in-repo
HTTPS / Caddy Optional caddy profile, enabled via COMPOSE_PROFILES Always on; HTTPS by default
Published ports App 1452/1462 and MySQL 3306 (localhost) published to host Only Caddy 80/443
Docker networks Single shared network Segmented frontend / backend
Healthchecks MySQL MySQL + FeedLand, with ordered startup
Email (single-user / testing) query-pending-confirmations.sh reads the pending code from the DB Built-in Mailpit catcher at /mail; swap in real SMTP via .env
Resource limits Memory limits on app + MySQL
Timezone Fixed (Europe/Berlin) TZ env, defaults to UTC
Local HTTP dev mode generate-env.sh --http-localhost
Schema updates Auto-fetches latest from upstream at startup Vendored (re-vendor to update)
Ops scripts env + confirmations env + backup + migrate

Each row is a trade-off, not a verdict — the original's runtime schema fetch tracks upstream automatically, and its localhost mode is a nice touch for local development. This repo trades some of that convenience for reproducibility and a smaller exposed surface.

Prerequisites

  • Docker and Docker Compose (v2)
  • A domain name with DNS pointing to your server
  • Ports 80 and 443 open

Quick Start

# 1. Generate .env (prompts for domain, generates MySQL passwords)
./scripts/generate-env.sh

# 2. Start everything
docker compose up -d

All four services will start. Caddy automatically obtains a TLS certificate from Let's Encrypt. Emails are caught by the built-in Mailpit and viewable at https://your-domain.com/mail.

Configuration

All settings are controlled via .env. See .env.example for the full list.

Required

Variable Description
FEEDLAND_DOMAIN Your domain name (must resolve to this server)
MYSQL_ROOT_PASSWORD MySQL root password
MYSQL_PASSWORD MySQL password for the feedland user

SMTP

By default, emails go to the built-in Mailpit mail catcher. This works well for single-user / self-hosted setups — confirmation emails are viewable at https://your-domain.com/mail.

For multi-user setups where emails need to reach real inboxes, configure a real SMTP server in .env:

Variable Default Description
SMTP_HOST mailpit SMTP server hostname
SMTP_PORT 1025 SMTP port
SMTP_USERNAME (empty) SMTP username
SMTP_PASSWORD (empty) SMTP password
MAIL_SENDER feedland@localhost "From" address for emails

Example for a real SMTP provider:

SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=you@gmail.com
SMTP_PASSWORD=your_app_password
MAIL_SENDER=noreply@your-domain.com

Optional

Variable Default Description
MYSQL_DATABASE feedland MySQL database name
MYSQL_USER feedland MySQL username
FEEDLAND_PRODUCT_NAME FeedLand Product name in UI and emails
ENABLE_NEW_USERS true Allow new user registrations
TZ UTC Timezone (IANA format)

Securing the Mailpit UI (/mail)

FeedLand sign-in is passwordless: it emails a magic link, and clicking it logs you in — no password check afterward. When Mailpit is the mail catcher (the default), every one of those sign-in links lands in it. Anyone who can open /mail can read another user's sign-in link and take over their account. This isn't a theoretical risk on a publicly-reachable instance — it's the same as leaving login unauthenticated.

For that reason, /mail is protected with HTTP basic auth via Caddy, using these .env variables:

Variable Default Description
MAILPIT_USER mail Basic-auth username for /mail
MAILPIT_PASSWORD (random) Plaintext password — shown once, save it
MAILPIT_PASSWORD_HASH (generated) Bcrypt hash of the password; this is what Caddy actually checks

./scripts/generate-env.sh generates a random password and its bcrypt hash automatically and prints the credentials once at the end — save them. To set your own instead, hash it with:

docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword'

and put the plaintext in MAILPIT_PASSWORD and the hash in MAILPIT_PASSWORD_HASH.

Important — escape every $ as $$ in MAILPIT_PASSWORD_HASH. Docker Compose interpolates any $name-shaped substring it finds inside .env values, and bcrypt hashes are full of $-delimited fields (e.g. $2a$14$tjDzswdOFUM...), so a hash pasted in raw gets silently mangled before it ever reaches Caddy — basic auth then fails with no obvious cause. Double every $ to $$ when you add the hash by hand:

docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword'
# e.g. output: $2a$14$tjDzswdOFUM6b3v9s7xQyO...
# write it into .env with every $ doubled:
# MAILPIT_PASSWORD_HASH=$$2a$$14$$tjDzswdOFUM6b3v9s7xQyO...

./scripts/generate-env.sh does this escaping for you automatically. This is why MAILPIT_PASSWORD_HASH looks "doubled" if you open .env directly — that's expected, not a bug; Compose un-escapes $$ back to a literal $ when it passes the value through to the container. You can confirm the container sees the real, unescaped hash with docker compose exec caddy printenv MAILPIT_PASSWORD_HASH.

Upgrading an existing deployment: MAILPIT_PASSWORD_HASH is now required — docker compose up -d will refuse to start the caddy service without it. Add all three variables to your existing .env before upgrading, remembering to double the $ characters in the hash as described above:

docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword'
# then add to .env (double every $ in the hash!):
#   MAILPIT_USER=mail
#   MAILPIT_PASSWORD=yourpassword
#   MAILPIT_PASSWORD_HASH=<output of the command above, with $ doubled to $$>
docker compose up -d

Rotating the credentials: generate a new password and hash the same way, update MAILPIT_PASSWORD and MAILPIT_PASSWORD_HASH in .env, then docker compose up -d to restart Caddy with the new values.

Recommended for production: configure a real SMTP relay (see the SMTP section above) so sign-in links go straight to users' real inboxes instead of sitting in Mailpit. With real SMTP configured, Mailpit is no longer in the sign-in path — it's just a fallback catcher for anything that doesn't get routed elsewhere — but it stays basic-auth protected either way.

Architecture

Internet → Caddy (ports 80/443, automatic HTTPS)
              ├─ /mail  → Mailpit (email web UI)
              ├─ ws://  → FeedLand :1462 (WebSocket)
              └─ /*     → FeedLand :1452 (HTTP)
                            ↓ (backend network)
                         MySQL 8.0
  • Only Caddy is accessible from the internet
  • FeedLand, MySQL, and Mailpit are on internal Docker networks only
  • Caddy cannot reach MySQL (network segmentation)

Creating Your First User

  1. Open https://your-domain.com in a browser
  2. Click "Sign up" and enter a username and email
  3. Go to https://your-domain.com/mail to find the confirmation email
  4. Click the confirmation link

If you've configured real SMTP, the email arrives in the user's real inbox instead.

Backup and Restore

Backup

# Create a compressed backup
./scripts/backup.sh

# Backups are stored in ./backups/
# Last 7 are retained by default (set RETAIN=N to change)

Restore

# Restore from a backup
gunzip < backups/feedland-2026-02-17_120000.sql.gz | \
  docker compose exec -T mysql \
  mysql -uroot -p"$MYSQL_ROOT_PASSWORD" feedland

Scheduled Backups

Add to crontab (crontab -e):

0 3 * * * cd /path/to/feedland-docker && ./scripts/backup.sh >> backups/cron.log 2>&1

Updating FeedLand

# Pull the latest image
docker compose pull feedland

# Restart with the new image
docker compose up -d

If the update includes database schema changes, check the FeedLand worknotes and apply migrations:

# Save the migration SQL to a file, then:
./scripts/migrate.sh path/to/migration.sql

Common Commands

# View logs (all services)
docker compose logs -f

# View logs (one service)
docker compose logs -f feedland

# Stop all services
docker compose down

# Restart a single service
docker compose restart feedland

# Check service health
docker compose ps

# Open a MySQL shell
docker compose exec mysql mysql -ufeedland -p feedland

Troubleshooting

Services won't start / "Set FEEDLAND_DOMAIN in .env" Ensure .env exists with all required variables set. Run ./scripts/generate-env.sh if you haven't.

Caddy fails to get a certificate

  • Verify your domain's DNS A/AAAA record points to this server
  • Ensure ports 80 and 443 are open and not used by another service
  • Check Caddy logs: docker compose logs caddy
  • For testing, uncomment the staging CA line in Caddyfile to avoid rate limits

FeedLand healthcheck failing

  • Check FeedLand logs: docker compose logs feedland
  • Verify MySQL is healthy: docker compose ps mysql
  • Check the generated config: docker compose exec feedland cat /project/config.json

MySQL won't start

  • Check logs: docker compose logs mysql
  • If schema was corrupted, you may need to remove the volume: docker compose down -v (this deletes all data)

Email confirmation not working

  • Default setup: Check https://your-domain.com/mail for caught emails
  • Real SMTP: Verify SMTP settings in .env, check docker compose logs feedland for errors