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