Open-source, multi-tenant publishing platform for blogs and small business sites. Each site runs on its own subdomain, content is written in Markdown or HTML, and the whole project deploys as a single application.
A hosted instance runs at masthead.site — sign up there to use Masthead without operating it yourself, or follow the instructions below to self-host.
- Unlimited sites, one account. Manage multiple brands or products from a single dashboard.
- Subdomains out of the box, custom domains on paid sites. Every site is
served at
<slug>.<your-domain>; paid sites can attach their own domain. - Markdown and HTML editors. Live preview, syntax-aware editor.
- Built-in blog pages. Mark any page as a blog and the post list is generated for you. Set it as the homepage to make it the front page.
- Image library. Upload once, paste ready-made Markdown or HTML embed snippets into any post or page. PDFs get a page-one thumbnail.
- Themes and a theme marketplace. Ships with one built-in theme; more can be installed from the marketplace or uploaded as a package.
- Site import. Bring posts, pages and images over from a
.zipexport (Hugo sites supported). - Safe HTML by default. All rendered content is run through an allowlist sanitizer before reaching the browser.
- Paid plans (optional). Custom domains, statistics and site collaborators are gated behind a per-site license, billed through Stripe.
- Phoenix LiveView app, single Postgres database, single OTP release. Background jobs (email, PDF thumbnails, maintenance) run on Oban in the same database.
- Multi-tenancy resolved at the
Host:header byMastheadWeb.Plugs.Subdomain— site rows in thesitestable are looked up by subdomain (or custom domain) on every request and assigned toconn.assigns.current_site. - Two routers:
MastheadWeb.PublicRouterserves site-scoped public URLs (/,/posts/:slug,/:slug);MastheadWeb.Routerserves the admin and marketing surface on the bare app host. - Object storage is pluggable via
Masthead.Storage.Adapter. Ships with a local-disk adapter for development and an S3-compatible adapter for production (works with any S3-compatible provider).
Requires Elixir 1.18+, Erlang/OTP 28+, Postgres 14+ and Node.js (npm, for the editor's JavaScript dependencies).
mix deps.get
(cd assets && npm install)
mix ecto.setup # creates DB, migrates, seeds a demo site
mix phx.serverOpen:
http://localhost:4000— sign in / adminhttp://localhost:4000/admin— platform admin consolehttp://demo.lvh.me:4000— seeded demo site
*.lvh.me is a public DNS record that resolves to 127.0.0.1, so
subdomain-based tenancy works in dev without /etc/hosts edits.
Seeded credentials (a platform admin):
email: admin@example.com
password: password1234
In dev, a .env file in the project root is loaded automatically, so you
can put any of the variables below there (e.g. to try social sign-in).
All prod config is read from environment variables (see
config/runtime.exs).
The release refuses to boot without these.
| Var | Purpose |
|---|---|
DATABASE_URL |
Postgres connection string, e.g. ecto://USER:PASS@HOST/DATABASE |
SECRET_KEY_BASE |
Cookie / LiveView token signing key (mix phx.gen.secret) |
PHX_HOST |
The canonical hostname, e.g. masthead.example.com. Falls back to example.com, so set it. |
RESEND_API_KEY |
Resend API key. Account confirmation and password reset email go through Resend. Site creators are also added to your Resend contacts, which needs a full-access key; with a sending-only key that step is skipped. Backfill existing site creators with bin/masthead rpc "Masthead.Workers.CreateContact.backfill()". |
MAIL_FROM |
Sender address on your Resend-verified domain, e.g. hello@masthead.example.com |
| Var | Default | Purpose |
|---|---|---|
APP_HOSTS |
PHX_HOST |
Comma-separated hostnames treated as the bare app surface. Subdomains of any of these are routed as sites. The first one is the CNAME target for custom domains. |
PHX_SERVER |
— | true to start the HTTP server. bin/server sets it for you. |
PORT |
4000 |
HTTP port |
POOL_SIZE |
10 |
Database connection pool size |
ECTO_IPV6 |
— | true to connect to Postgres over IPv6 |
DNS_CLUSTER_QUERY |
— | DNS name used to cluster multiple nodes |
MAIL_FROM_NAME |
Masthead |
Display name on outgoing email |
FEATURES |
— | Comma-separated optional features. homepage shows the marketing homepage at /; without it, / redirects to the login screen. |
If BUCKET_NAME is set, the S3 storage adapter takes over. Without it,
uploads are written to priv/uploads inside the release — fine on a
machine with a persistent disk, but lost on every redeploy in a
container unless that path is a mounted volume.
| Var | Default | Purpose |
|---|---|---|
BUCKET_NAME |
— | Bucket to store uploads in |
AWS_ACCESS_KEY_ID |
— | Required when BUCKET_NAME is set |
AWS_SECRET_ACCESS_KEY |
— | Required when BUCKET_NAME is set |
AWS_ENDPOINT_URL_S3 |
https://fly.storage.tigris.dev |
S3 API endpoint of your provider |
AWS_REGION |
auto |
Bucket region |
AWS_PUBLIC_URL_S3 |
https://<bucket>.<endpoint-host> |
Base URL files are served from, e.g. a CDN or custom domain in front of the bucket |
The bucket must allow public reads (bucket policy or per-object ACL) for image embeds to resolve in browsers.
A provider's button only works once its client ID is set. Register the
OAuth app with callback URL https://<PHX_HOST>/auth/<provider>/callback
(google or github).
| Var | Purpose |
|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
Continue with Google |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
Continue with GitHub |
Without Stripe the install runs fine, it just can't sell licenses. A
platform admin can still unlock paid features for any site by gifting
license months from the /admin console.
| Var | Default | Purpose |
|---|---|---|
STRIPE_SECRET_KEY |
— | Stripe secret API key |
STRIPE_WEBHOOK_SECRET |
— | Signing secret (whsec_…) of a webhook endpoint at https://<PHX_HOST>/webhooks/payments, subscribed to customer.subscription.* events |
LICENSE_PRICE_MONTHLY_CENTS |
500 |
Monthly price, in cents |
LICENSE_PRICE_YEARLY_CENTS |
5000 |
Yearly price, in cents |
LICENSE_CURRENCY |
eur |
Currency for checkout and price display |
Prices are created inline at checkout, so no Stripe products need to exist.
Custom domains get their TLS certificates through the Fly.io API, so this
feature only works when deployed on Fly. Site owners point a CNAME at the
first APP_HOSTS entry and verify ownership with a _masthead-verify TXT
record.
| Var | Purpose |
|---|---|
FLY_API_TOKEN |
Fly API token allowed to manage the app's certificates |
FLY_APP_NAME |
Name of the Fly app |
| Var | Purpose |
|---|---|
GOOGLE_ANALYTICS_ID |
Google Analytics measurement ID for the app's own pages |
CHATWOOT_WEBSITE_TOKEN |
Website token of a Chatwoot inbox. The support chat widget is only loaded when this is set. |
CHATWOOT_HMAC_TOKEN |
The inbox's identity-validation token, used to sign the logged-in user passed to the widget |
The repo ships a Dockerfile that builds a release, and
fly.toml for Fly.io. Any host that runs a container works.
-
Provision Postgres and, ideally, an S3-compatible bucket (see above).
-
Set the required environment variables.
-
Build and run the image. The container starts with
bin/server. -
Run migrations on every deploy with
bin/migrate(on Fly this is therelease_commandinfly.toml). -
Point wildcard DNS (
*.your-domain.com) and the bare domain at the app, and serve both over HTTPS with a wildcard certificate. -
Sign up through the app, then make yourself a platform admin from a remote console:
bin/masthead remote
"you@example.com" |> Masthead.Accounts.get_user_by_email() |> Masthead.Accounts.set_admin(true)
On Fly:
fly ssh console -C "/app/bin/masthead remote".
Wildcard TLS issuance via Let's Encrypt requires the DNS-01 challenge, since HTTP-01 doesn't support wildcards. Most modern hosting providers (or a Caddy / Traefik reverse proxy) handle this automatically.
PDF thumbnails need pdftoppm (poppler-utils), which the Docker image
already includes. Without it PDFs simply show a file badge.
lib/
├── masthead/ # business logic
│ ├── accounts/ # users, session + social auth
│ ├── actions/ # per-site checklist / todos
│ ├── content/ # posts, pages, HTML sanitizer
│ ├── custom_domains/ # domain verification, Fly certs
│ ├── sites/ # tenant sites
│ ├── storage/ # local + S3 adapters
│ ├── themes/ # theme loading, rendering, packages
│ ├── uploads/ # file metadata, thumbnails
│ └── workers/ # Oban background jobs
└── masthead_web/
├── plugs/subdomain.ex # host → site resolution
├── public_router.ex # site-scoped routes
├── router.ex # admin + marketing routes
├── controllers/ # public, auth, webhook controllers
└── live/admin/ # all admin LiveViews
priv/themes/ # built-in theme
Issues and pull requests welcome at github.com/dijkstrasoftware/masthead.
MIT.