- Shell 97.9%
- HTML 2.1%
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> |
||
|---|---|---|
| db | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| Caddyfile | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| emailtemplate.html | ||
| entrypoint.sh | ||
| README.md | ||
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
- Open
https://your-domain.comin a browser - Click "Sign up" and enter a username and email
- Go to
https://your-domain.com/mailto find the confirmation email - 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
Caddyfileto 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/mailfor caught emails - Real SMTP: Verify SMTP settings in
.env, checkdocker compose logs feedlandfor errors