Mastodon Setup with Docker and Caddy

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. :rocket:

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 the mastodon-web container to the Caddy webserver. Caddy serves directy static files for improved speed. Awesome! :star2:

  • 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, use SMTP_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

  1. 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.

  2. 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