Mastodon Setup with Docker and Caddy
Learn how to setup a Mastodon server using Docker and Caddy. This setup comes with automated SSL.
In my previous post, we setup a Mastodon server using Docker and nginx-proxy. In this post, we use instead the web server Caddy. I’ve only discovered Caddy a week ago. The configuration of Mastodon is now even simpler and shorter. Caddy redirects traffic automatically from HTTP to HTTPS and configures HTTPS for all domains via Let’s Encryption.
Our starting point is the docker-compose.yml shipped with the Mastodon code. Why is it not enough? It assumes you setup up proxy with HTTPS endpoints yourself. So let’s integrate this as well in Docker.
Setup
Few remarks to start with:
- my testing system: Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-97-generic x86_64)
- install first some software with
apt install docker docker.io caddy jq git
-
create an unprivileged user account, e.g.
mastodon
adduser --disabled-login mastodon adduser mastodon docker adduser mastodon sudo # optional, remove later su mastodon # switch to that user
-
my docker compose: Docker Compose version v2.2.3 (based on go)
install docker compose in 3 lines:
mkdir -p ~/.docker/cli-plugins curl -sSL https://github.com/docker/compose/releases/download/v2.2.3/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose chmod +x ~/.docker/cli-plugins/docker-compose
-
my testing domain (for this example): social.host
-
my dot-env file
.env
for docker compose:LETS_ENCRYPT_EMAIL=admin-mail@social.host MASTODON_DOMAIN=social.host FRONTEND_SUBNET="172.22.0.0/16" # check the latest version here: https://hub.docker.com/r/tootsuite/mastodon/tags MASTODON_VERSION=v3.4.6
-
I have commented out
build: .
, because I prefer to rely on the official images from Docker Hub. -
With little effort, we enable as well full-text search with elasticsearch.
-
The setup places all databases and uploaded files in the folder
mastodon
. -
We use a named volume
mastodon-public
to expose the static files from themastodon-web
container to the Caddy webserver. Caddy serves directy static files for improved speed. Awesome! -
The setup comes with the Mastodon Twitter Crossposter. You need to setup an extra subdomain for it. Remove it from the
docker-compose.yml
in case you have no use for it. -
Using
extra_host
, we expose with"host.docker.internal:host-gateway"
the Docker host to the Mastodon Sidekiq container in case that you configure Mastodon to use a mail transfer agent (e.g. postfix) running on the host. In that case, useSMTP_SERVER=host.docker.internal
. - Consider to replace the git repository to build the crossposter with a local copy for more control over updates.
# file: 'docker-compose.yml'
version: "3.7"
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
container_name: caddy
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/etc-caddy:/etc/caddy
- ./caddy/data:/data # Optional
- ./caddy/config:/config # Optional
- ./caddy/logs:/logs
- mastodon-public:/srv/mastodon/public:ro
env_file: .env
# helps crossposter resolve the mastodon server internally
hostname: "${MASTODON_DOMAIN}"
networks:
frontend:
aliases:
- "${MASTODON_DOMAIN}"
networks:
- frontend
- backend
mastodon-db:
restart: always
image: postgres:14-alpine
container_name: "mastodon-db"
healthcheck:
test: pg_isready -U postgres
environment:
POSTGRES_HOST_AUTH_METHOD: trust
volumes:
- "./mastodon/postgres:/var/lib/postgresql/data"
networks:
- backend
mastodon-redis:
restart: always
image: redis:6.0-alpine
container_name: "mastodon-redis"
healthcheck:
test: redis-cli ping
volumes:
- ./mastodon/redis:/data
networks:
- backend
mastodon-elastic:
restart: always
image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2
container_name: "mastodon-elastic"
healthcheck:
test: curl --silent --fail localhost:9200/_cluster/health || exit 1
environment:
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
cluster.name: es-mastodon
discovery.type: single-node
bootstrap.memory_lock: "true"
volumes:
- ./mastodon/elasticsearch:/usr/share/elasticsearch/data
networks:
- backend
ulimits:
memlock:
soft: -1
hard: -1
mastodon-web:
restart: always
image: "tootsuite/mastodon:${MASTODON_VERSION}"
container_name: "mastodon-web"
healthcheck:
test: wget -q --spider --proxy=off localhost:3000/health || exit 1
env_file: mastodon.env.production
environment:
LOCAL_DOMAIN: "${MASTODON_DOMAIN}"
SMTP_FROM_ADDRESS: "notifications@${MASTODON_DOMAIN}"
ES_HOST: mastodon-elastic
ES_ENABLED: true
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
expose:
- "3000"
depends_on:
- mastodon-db
- mastodon-redis
- mastodon-elastic
volumes:
# https://www.digitalocean.com/community/tutorials/how-to-share-data-between-docker-containers
- mastodon-public:/opt/mastodon/public # map static files in volume for caddy
- ./mastodon/public/system:/opt/mastodon/public/system
networks:
- frontend
- backend
extra_hosts:
- "host.docker.internal:host-gateway"
mastodon-streaming:
restart: always
image: "tootsuite/mastodon:${MASTODON_VERSION}"
container_name: "mastodon-streaming"
healthcheck:
test: wget -q --spider --proxy=off localhost:4000/api/v1/streaming/health || exit 1
]
env_file: mastodon.env.production
environment:
LOCAL_DOMAIN: "${MASTODON_DOMAIN}"
SMTP_FROM_ADDRESS: "notifications@${MASTODON_DOMAIN}"
ES_HOST: mastodon-elastic
ES_ENABLED: true
command: node ./streaming
expose:
- "4000"
depends_on:
- mastodon-db
- mastodon-redis
networks:
- frontend
- backend
mastodon-sidekiq:
restart: always
image: "tootsuite/mastodon:${MASTODON_VERSION}"
container_name: "mastodon-sidekiq"
healthcheck:
test: ps aux | grep '[s]idekiq\ 6' || false
env_file: mastodon.env.production
environment:
LOCAL_DOMAIN: "${MASTODON_DOMAIN}"
SMTP_FROM_ADDRESS: "notifications@${MASTODON_DOMAIN}"
ES_HOST: mastodon-elastic
ES_ENABLED: true
command: bundle exec sidekiq
depends_on:
- mastodon-db
- mastodon-redis
volumes:
- ./mastodon/public/system:/mastodon/public/system
networks:
- frontend
- backend
extra_hosts:
- "host.docker.internal:host-gateway"
crossposter-db:
restart: always
image: postgres:14-alpine
container_name: "crossposter-db"
healthcheck:
test: pg_isready -U postgres
environment:
POSTGRES_HOST_AUTH_METHOD: trust
volumes:
- ./crossposter/postgres:/var/lib/postgresql/data
networks:
- backend
crossposter-redis:
restart: always
image: redis:6.0-alpine
container_name: "crossposter-redis"
healthcheck:
test: redis-cli ping
volumes:
- ./crossposter/redis:/data
networks:
- backend
crossposter-web:
restart: always
build: https://github.com/renatolond/mastodon-twitter-poster.git#main
image: mastodon-twitter-poster
container_name: "crossposter-web"
env_file: crossposter.env.production
environment:
CROSSPOSTER_DOMAIN: "https://crossposter.${MASTODON_DOMAIN}"
expose:
- "3000"
depends_on:
- crossposter-db
networks:
- frontend
- backend
crossposter-sidekiq:
restart: always
build: https://github.com/renatolond/mastodon-twitter-poster.git#main
image: mastodon-twitter-poster
container_name: "crossposter-sidekiq"
healthcheck:
test: ps aux | grep '[s]idekiq\ 6' || false
env_file: crossposter.env.production
environment:
ALLOWED_DOMAIN: "${MASTODON_DOMAIN}"
CROSSPOSTER_DOMAIN: "https://crossposter.${MASTODON_DOMAIN}"
command: bundle exec sidekiq -c 5 -q default
depends_on:
- crossposter-db
- crossposter-redis
networks:
- frontend
- backend
volumes:
mastodon-public:
networks:
frontend:
name: "${COMPOSE_PROJECT_NAME}_frontend"
ipam:
config:
- subnet: "${FRONTEND_SUBNET}"
backend:
name: "${COMPOSE_PROJECT_NAME}_backend"
internal: true
The web server Caddy is configured using a Caddyfile
stored in ./caddy/etc-caddy
. I started with a config I found on Github.
# file: 'Caddyfile'
# kate: indent-width 8; space-indent on;
{
# Global options block. Entirely optional, https is on by default
# Optional email key for lets encrypt
email {$LETS_ENCRYPT_EMAIL}
# Optional staging lets encrypt for testing. Comment out for production.
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
# admin off
}
{$MASTODON_DOMAIN} {
log {
# format single_field common_log
output file /logs/access.log
}
root * /srv/mastodon/public
encode gzip
@static file
handle @static {
file_server
}
handle /api/v1/streaming* {
reverse_proxy mastodon-streaming:4000
}
handle {
reverse_proxy mastodon-web:3000
}
header {
Strict-Transport-Security "max-age=31536000;"
}
header /sw.js Cache-Control "public, max-age=0";
header /emoji* Cache-Control "public, max-age=31536000, immutable"
header /packs* Cache-Control "public, max-age=31536000, immutable"
header /system/accounts/avatars* Cache-Control "public, max-age=31536000, immutable"
header /system/media_attachments/files* Cache-Control "public, max-age=31536000, immutable"
handle_errors {
@5xx expression `{http.error.status_code} >= 500 && {http.error.status_code} < 600`
rewrite @5xx /500.html
file_server
}
}
crossposter.{$MASTODON_DOMAIN} {
log {
# format single_field common_log
output file /logs/access-crossposter.log
}
encode gzip
handle {
reverse_proxy crossposter-web:3000
}
}
With these file in place, create a few more folders and launch the setup of the instance. If the instance has been setup before, a database setup may be enough.
# mastodon
touch mastodon.env.production
sudo chown 991:991 mastodon.env.production
mkdir -p mastodon/public
sudo chown -R 991:991 mastodon/public
mkdir -p mastodon/elasticsearch
sudo chmod g+rwx mastodon/elasticsearch
sudo chgrp 0 mastodon/elasticsearch
# first time: setup mastodon
# https://github.com/mastodon/mastodon/issues/16353 (on RUBYOPT)
docker compose run --rm -v $(pwd)/mastodon.env.production:/opt/mastodon/.env.production -e RUBYOPT=-W0 web bundle exec rake mastodon:setup
# subsequent times: skip generation of config and only setup database
docker compose run --rm -v $(pwd)/mastodon.env.production:/opt/mastodon/.env.production web bundle exec rake db:setup
# crossposter
mkdir crossposter
docker-compose run --rm crossposter-web bundle exec rake db:setup
# launch mastodon and crossposter
docker compose run -d
# look into the logs, -f for live logs
docker compose logs -f
Troubleshooting
-
I had much problems to let the mastodon container connect to a mail transport agent (MTA) of my host. Eventually, I solved it with an extra filewall rule:
ufw allow proto tcp from any to 172.17.0.1 port 25
. -
The mail issue can be avoided by a) using a SaaS such as (mailgun/mailjet/sendinblue) or b) using another Docker container with postfix that is in the frontend network as well. Look at Peertube’s docker-compose file for some inspiration.
References
- https://caddyserver.com
-
https://gist.github.com/yukimochi/bb7c90cbe628f216f821e835df1aeac1?permalink_comment_id=3607303#gistcomment-3607303 (
Caddyfile
for Mastodon on Github)