Side profile illustration of Mammut americanum (American mastodon)

How I Run Mastodon

Banner image: “Reconstruction of Mammut americanum (American mastodon) (Pleistocene; North America) 2” by James St. John, licensed under CC BY 2.0

Introduction

This is how I deployed a small Mastodon server with Docker Compose on an Amazon EC2 instance. I won’t go into the moderation side of running a server; this is just about getting it up and running.

Docker Compose

I use Docker Compose to manage the containers, networking, and storage. Since I run more than one application on my EC2 hosts, I use a separate Docker Compose project for an Nginx container that reverse proxies all of the apps on a host.

C4Container title Docker Containers Person(user, User, "A Mastodon user", $tags="v1.0") UpdateLayoutConfig("4", "1") Container_Boundary(c1, "Reverse Proxy") { Container(reverse_proxy, "Nginx reverse proxy", "Nginx", "Proxies all web applications on the EC2 host") } Container_Boundary(c3, "Mastodon") { Container(mastodon, "Mastodon Rails App", "Ruby, Rails", "Puma application server for the Mastodon app") Container(streaming, "Mastodon Streaming API", "Javascript, NodeJS", "Mastodon streaming API server") Container(web_static, "Static Web Server", "Nginx", "Serves static web content") Container(redis, "Redis", "Redis", "Caches web content") Container(sidekiq, "Sidekiq", "Sidekiq", "Runs asynchronous tasks for the app server") ContainerDb(database, "Database", "SQL Database", "Stores application data") } Rel(user, reverse_proxy, "", "HTTPS, WebSocket") UpdateRelStyle(user, reverse_proxy, $offsetY="-40") Rel(reverse_proxy, mastodon, "", "HTTP") UpdateRelStyle(reverse_proxy, mastodon, $offsetX="-75", $offsetY="-25") Rel(reverse_proxy, streaming, "", "WebSocket") UpdateRelStyle(reverse_proxy, streaming, $offsetX="-25", $offsetY="75") Rel(reverse_proxy, web_static, "", "HTTP") UpdateRelStyle(reverse_proxy, web_static, $offsetX="175", $offsetY="75") Rel(mastodon, database, "") Rel(mastodon, redis, "") Rel(mastodon, sidekiq, "") Rel(streaming, database, "") Rel(streaming, sidekiq, "")

There’s an example docker-compose.yml in Mastodon’s Github repo, but I’ve modified their version a bit. You can see the full diff on its own page; here I’ll highlight some of the key changes.

Database Container

     restart: always
     image: postgres:14-alpine
     shm_size: 256mb
-    networks:
-      - internal_network
     healthcheck:
-      test: ['CMD', 'pg_isready', '-U', 'postgres']
+      test: ['CMD', 'pg_isready', '-U', 'mastodon', 'mastodon_production']
     volumes:
-      - ./postgres14:/var/lib/postgresql/data
-    environment:
-      - 'POSTGRES_HOST_AUTH_METHOD=trust'
+      - db-data:/var/lib/postgresql/data
+    env_file: db.env
  • I removed the network section because I use the project’s default network as the internal network.
  • I changed the Postgres username and database name, and set a password for the database. These values are read from the db.env file.

App Container

Next we have the Puma application server that serves the Mastodon Rails app:

-  web:
+  app:
     build: .
-    image: tootsuite/mastodon
     restart: always
-    env_file: .env.production
+    env_file: mastodon.env
     command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
-    networks:
-      - external_network
-      - internal_network
     healthcheck:
-      # prettier-ignore
       test: ['CMD-SHELL', 'wget -q --spider --proxy=off localhost:3000/health || exit 1']
-    ports:
-      - '127.0.0.1:3000:3000'
+    expose:
+      - 3000
+    networks:
+      reverse_proxy_web_internal:
+        aliases:
+          - "mastodon-app"
+      default:
     depends_on:
       - db
       - redis
-      # - es
     volumes:
-      - ./public/system:/mastodon/public/system
+      - system-data:/opt/mastodon/public/system
  • I renamed the web container to app to avoid confusion with the two Nginx web servers.
  • This container is part of the reverse-proxy-web-internal network and is not exposed directly to clients.
  • I’m using a named Docker volume for the system directory.

Streaming API Container

   streaming:
     build: .
-    image: tootsuite/mastodon
     restart: always
-    env_file: .env.production
+    env_file: mastodon.env
     command: node ./streaming
-    networks:
-      - external_network
-      - internal_network
     healthcheck:
       # prettier-ignore
       test: ['CMD-SHELL', 'wget -q --spider --proxy=off localhost:4000/api/v1/streaming/health || exit 1']
-    ports:
-      - '127.0.0.1:4000:4000'
+    expose:
+      - 4000
+    networks:
+      reverse_proxy_web_internal:
+        aliases:
+          - mastodon-streaming
+      default:
     depends_on:
       - db
       - redis

This container is also part of the reverse proxy network and is not exposed directly to clients.

Static Web Server Container

This container isn’t present in the upstream configuration—Puma is configured to serve static files from the app container.

  nginx:
    image: nginx:mainline-alpine
    restart: always
    depends_on:
      - app
    networks:
      reverse_proxy_web_internal:
        aliases:
          - mastodon-web
      default:
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - system-data:/home/mastodon/live/public/system

Reverse Proxy Container

I have a separate Docker Compose project for the EC2 host to reverse proxy all of the apps on the instance. This is the Nginx config for the Mastodon project.

map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=CACHE:10m inactive=7d max_size=1g;

upstream app {
    server mastodon-app:3000 fail_timeout=0;
}

upstream web-static {
  server mastodon-web;
}

upstream streaming {
    server mastodon-streaming:4000 fail_timeout=0;
}

server {
  listen 80;
  server_name social.theokadas.com;
  return 301 https://social.theokadas.com$request_uri;
}

server {
  listen 443 ssl http2;
  server_name social.theokadas.com;

  ssl_certificate /etc/letsencrypt/live/social.theokadas.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/social.theokadas.com/privkey.pem;

  # Allow large attachments
  client_max_body_size 128M;

  location / {
    try_files @web @app;
  }

  location ^~ /api/v1/streaming/ {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Proxy "";

    proxy_pass http://streaming;
    proxy_buffering off;
    proxy_redirect off;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    tcp_nodelay on;
  }

  location @web {
    proxy_pass http://web-static;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $host;
  }

  location @app {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Proxy "";
    proxy_pass_header Server;

    proxy_pass http://app;
    proxy_buffering on;
    proxy_redirect off;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_cache CACHE;
    proxy_cache_valid 200 7d;
    proxy_cache_valid 410 24h;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    add_header X-Cached $upstream_cache_status;

    tcp_nodelay on;
  }
}

server {
  listen 443 ssl http2;
  server_name theokadas.com;

  ssl_certificate /etc/letsencrypt/live/theokadas.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/theokadas.com/privkey.pem;

	location /.well-known/webfinger {
		return 301 https://social.theokadas.com$request_uri;
	}
}

Note that the Mastodon website is at social.theokadas.com, but I also have a server block listening for theokadas.com. This is to redirect webfinger requests to Mastodon since I’m using theokadas.com as the Mastodon $LOCAL_DOMAIN. You won’t need that block if your $LOCAL_DOMAIN matches your $WEB_DOMAIN.