Deploy Mastodon on Pethost in one click
Your own server in the fediverse: posts, follows and replies across Mastodon and beyond
What happens
- You sign in to Pethost or make an account, with a passkey, GitHub or Google.
- No machine yet? You pick a plan, and we prepare one for you. The link waits in your browser until it is ready.
- New project opens at the app. You fill in what it asks for and click Create project: Username, Email, Admin password.
- In a minute or two the app is live at its own address with HTTPS. Its secrets are made for it at random, and its data is in the nightly backup.
- It is a project like any other: its compose.yaml, its .env, its logs and a terminal are in the panel, and your agent can change it, roll it back or restore it.
First steps
Sign in with the email and password you gave: you are the server’s owner, with the username you gave. The email must be one that receives mail: Mastodon checks its domain, and the deploy fails without that. Names such as admin or root are reserved by Mastodon. Sign-ups are closed; open them under Preferences, Administration, Server settings. Mastodon sends no email until SMTP_SERVER, SMTP_LOGIN, SMTP_PASSWORD and SMTP_FROM_ADDRESS are in the project’s .env and the project restarts. Every account is tied to this address for good, so give the project its final address before you post. Uploaded media is kept in /opt/mastodon/public/system.
What it runs
The template is this compose.yaml and the variables it writes into the project’s .env. Pethost runs the file as it is written.
# Mastodon's docker-compose.yml with a small Caddy in front, which gives the streaming
# API its path on the project's address, as Mastodon's nginx does.
x-env: &env
LOCAL_DOMAIN: ${HOST}
DB_HOST: db
DB_PORT: "5432"
DB_USER: mastodon
DB_NAME: mastodon
DB_PASS: ${DB_PASSWORD}
REDIS_HOST: redis
REDIS_PORT: "6379"
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: ${AR_DETERMINISTIC_KEY}
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: ${AR_KEY_DERIVATION_SALT}
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: ${AR_PRIMARY_KEY}
VAPID_SEED: ${VAPID_SEED}
ES_ENABLED: "false"
WEB_CONCURRENCY: "1"
SMTP_SERVER: ${SMTP_SERVER:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_LOGIN: ${SMTP_LOGIN:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM_ADDRESS: ${SMTP_FROM_ADDRESS:-}
# Web push needs a VAPID key pair. Its private key is the 32 random bytes of VAPID_SEED,
# and the public key is derived from it, so web and sidekiq read the same pair and
# nothing is kept apart from .env.
x-vapid: &vapid |
eval "$$(ruby -ropenssl -rbase64 -e '
d = [ENV.fetch("VAPID_SEED")].pack("H*")
point = OpenSSL::PKey::EC::Group.new("prime256v1").generator.mul(OpenSSL::BN.new(d, 2))
puts "export VAPID_PRIVATE_KEY=#{Base64.urlsafe_encode64(d)}"
puts "export VAPID_PUBLIC_KEY=#{Base64.urlsafe_encode64(point.to_octet_string(:uncompressed))}"
')"
services:
proxy:
image: caddy:2.10-alpine
restart: unless-stopped
configs:
- source: caddy
target: /etc/caddy/Caddyfile
depends_on:
- web
- streaming
web:
image: ghcr.io/mastodon/mastodon:v4.7.3
restart: unless-stopped
environment:
<<: *env
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
VAPID: *vapid
# Every start migrates the database and seeds what is missing; the first one also
# makes the owner from the username, email and password given. Registrations stay
# closed, Mastodon's default, so nobody else can sign up until the owner opens them.
command:
- sh
- -c
- |
set -e
eval "$$VAPID"
bundle exec rails db:prepare db:seed
bundle exec rails runner '
owner = UserRole.find_by!(name: "Owner")
unless User.exists?(role_id: owner.id)
password = ENV.fetch("ADMIN_PASSWORD")
user = User.create!(
email: ENV.fetch("ADMIN_EMAIL"),
password: password,
password_confirmation: password,
account: Account.new(username: ENV.fetch("ADMIN_USERNAME")),
role: owner,
confirmed_at: Time.now.utc,
agreement: true,
approved: true,
bypass_registration_checks: true
)
# Closed registrations leave a new user waiting for approval.
user.approve!
end
'
exec bundle exec puma -C config/puma.rb
volumes:
- system:/opt/mastodon/public/system
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -s --noproxy localhost localhost:3000/health | grep -q OK"]
interval: 10s
timeout: 5s
retries: 5
start_period: 300s
streaming:
image: ghcr.io/mastodon/mastodon-streaming:v4.7.3
restart: unless-stopped
environment: *env
command: node ./streaming/index.js
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -s --noproxy localhost localhost:4000/api/v1/streaming/health | grep -q OK"]
interval: 10s
timeout: 5s
retries: 5
sidekiq:
image: ghcr.io/mastodon/mastodon:v4.7.3
restart: unless-stopped
environment:
<<: *env
VAPID: *vapid
command:
- sh
- -c
- |
eval "$$VAPID"
exec bundle exec sidekiq
volumes:
- system:/opt/mastodon/public/system
depends_on:
web:
condition: service_healthy
db:
image: postgres:17
restart: unless-stopped
shm_size: 256mb
environment:
POSTGRES_USER: mastodon
POSTGRES_DB: mastodon
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "mastodon"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7.4
restart: unless-stopped
volumes:
- redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
configs:
# The machine's proxy is trusted for X-Forwarded-Proto, so Rails sees HTTPS.
caddy:
content: |
{
admin off
auto_https off
servers {
trusted_proxies static private_ranges
}
}
:8080 {
reverse_proxy /api/v1/streaming* streaming:4000
reverse_proxy web:3000
}
volumes:
system:
db:
redis: