Mastodon is shaping up to be a real Twitter alternative contender especially in the tech-sphere so I wanted to try my hand at running my own instance in Docker. The experience for getting it up and running turned out to be the most streamlined but hopefully this guide will make things a little easier for others in the future.
For reference I’ve used this guide to install a local dev copy on my Macbook Pro as well as a production Ubuntu server.
Getting the Repo
Make a folder somewhere on your machine called mastodon. This will be your installation directory.
Inside mastodon we’ll clone the latest stable branch of the official repo
git clone https://github.com/mastodon/mastodon.git
cd mastodon
latest=$(git describe --tags `git rev-list --tags --max-count=1`)
git checkout $lastest -b ${latest}-branch
The reason we clone into a mastodon/mastodon subfolder is because our docker-compose will create a few volume directories and they aren’t gitignore’d by default so it’s just cleaner and safer this way.
Preparation
Copy the repo’s docker-composer.yml into your mastodon folder
cd ..
cp mastodon/docker-compose.yml ./
We won’t be making any code modifications so open it up and remove all the build . lines.
Also for some reason the latest version at the time of writing (4.2.1) has a docker-compose linking to mastodon 4.2.0 so update those numbers were possible. Your file should now look something like the following:
version: '3'
services:
db:
restart: always
image: postgres:14-alpine
shm_size: 256mb
networks:
- internal_network
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
volumes:
- ./postgres14:/var/lib/postgresql/data
environment:
- 'POSTGRES_HOST_AUTH_METHOD=trust'
redis:
restart: always
image: redis:7-alpine
networks:
- internal_network
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
volumes:
- ./redis:/data
# es:
# restart: always
# image: docker.elastic.co/elasticsearch/elasticsearch:7.17.4
# environment:
# - "ES_JAVA_OPTS=-Xms512m -Xmx512m -Des.enforce.bootstrap.checks=true"
# - "xpack.license.self_generated.type=basic"
# - "xpack.security.enabled=false"
# - "xpack.watcher.enabled=false"
# - "xpack.graph.enabled=false"
# - "xpack.ml.enabled=false"
# - "bootstrap.memory_lock=true"
# - "cluster.name=es-mastodon"
# - "discovery.type=single-node"
# - "thread_pool.write.queue_size=1000"
# networks:
# - external_network
# - internal_network
# healthcheck:
# test: ["CMD-SHELL", "curl --silent --fail localhost:9200/_cluster/health || exit 1"]
# volumes:
# - ./elasticsearch:/usr/share/elasticsearch/data
# ulimits:
# memlock:
# soft: -1
# hard: -1
# nofile:
# soft: 65536
# hard: 65536
# ports:
# - '127.0.0.1:9200:9200'
web:
# build: .
image: ghcr.io/mastodon/mastodon:v4.2.1
restart: always
env_file: .env.production
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'
depends_on:
- db
- redis
# - es
volumes:
- ./public/system:/mastodon/public/system
streaming:
# build: .
image: ghcr.io/mastodon/mastodon:v4.2.1
restart: always
env_file: .env.production
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'
depends_on:
- db
- redis
sidekiq:
# build: .
image: ghcr.io/mastodon/mastodon:v4.2.1
restart: always
env_file: .env.production
command: bundle exec sidekiq
depends_on:
- db
- redis
networks:
- external_network
- internal_network
volumes:
- ./public/system:/mastodon/public/system
healthcheck:
test: ['CMD-SHELL', "ps aux | grep '[s]idekiq\ 6' || false"]
## Uncomment to enable federation with tor instances along with adding the following ENV variables
## http_hidden_proxy=http://privoxy:8118
## ALLOW_ACCESS_TO_HIDDEN_SERVICE=true
# tor:
# image: sirboops/tor
# networks:
# - external_network
# - internal_network
#
# privoxy:
# image: sirboops/privoxy
# volumes:
# - ./priv-config:/opt/config
# networks:
# - external_network
# - internal_network
networks:
external_network:
internal_network:
internal: true
The es block is only used for full-text search which I haven’t used before. If you don’t need full-text search, leave it commented out.
Setting up PostgreSQL
Extract the volume folder and image names. We’ll need those in a sec. Also generate a random password to use with PostgreSQL.
grep "./postgr" docker-compose.yml | cut -d: -f1
# - ./postgres14
grep "image: postg" docker-compose.yml
# image: postgres:14-alpine
openssl rand -hex 12
# 6e8f157711eac1b0d5907365
On OSX volume directories are created inside a linux VM, not on your filesystem in the expected place. I found that when I created the folder manually before firing up the container, its files appeared as expected. So create the volume folder:
mkdir postgres14
Now fire up the container remembering to replace the vars were necessary:
#docker run --rm --name postgres \
#-v <volume path>:/var/lib/postgresql/data \
#-e POSTGRES_PASSWORD=<password> \
#-d <image name>
docker run --rm --name postgres \
-v $PWD/postgres14:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD="6e8f157711eac1b0d5907365" \
-d postgres:14-alpine
SSH in and create a user and password:
docker exec -it postgres psql -U postgres
CREATE USER mastodon WITH PASSWORD '<password>' CREATEDB;
exit
The container can now be stopped – it’ll be restarted during the setup process coming in a bit.
docker stop postgres
Mastodon Setup
Mastodon comes with a setup script that’ll dump a bunch of environment variables however due to an issue where some containers export variables to be used later in the process by containers that can’t accurately read them, it’s easier to just set up a couple beforehand.
Run the following remembering to replace my example DB password with your own. Also REDIS_PASSWORD is meant to be blank so just leave it like that.
cat << EOM > .env.production
DB_HOST=db
DB_PORT=5432
DB_NAME=mastodon
DB_USER=mastodon
DB_PASS=6e8f157711eac1b0d5907365
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
EOM
For the same reason as in the PostgreSQL section, we’ll create our volume folders now before firing everything up:
mkdir -p public/system
mkdir redis
Start the setup process:
docker-compose run --rm web bundle exec rake mastodon:setup
Enter the appropriate information:
Domain name: mastodon.localhost.com
Single user mode disables registrations and redirects the landing page to your public profile.
Do you want to enable single user mode? No
Are you using Docker to run Mastodon? Yes
PostgreSQL host: db
PostgreSQL port: 5432
Name of PostgreSQL database: mastodon
Name of PostgreSQL user: mastodon
Password of PostgreSQL user:
Database configuration works!
Redis host: redis
Redis port: 6379
Redis password:
Redis configuration works!
Do you want to store uploaded files on the cloud? yes
Provider Amazon S3
S3 bucket name: mastodon-cdn
S3 region: auto
S3 hostname: myid.r2.cloudflarestorage.com/mastodon-cdn
S3 access key: my_access_key
S3 secret key: my_secret_key
Do you want to access the uploaded files from your own domain? Yes
Domain for uploaded files: mastodon-cdn.mydomain.com
Do you want to send e-mails from localhost? No
SMTP server: smtp.api.createsend.com
SMTP port: 587
SMTP username: myusername
SMTP password: mypassword
SMTP authentication: plain
SMTP OpenSSL verify mode: none
Enable STARTTLS: auto
E-mail address to send e-mails “from”: Mastodon <mastodon@mydomain.com>
Send a test e-mail with this configuration right now? no
Do you want Mastodon to periodically check for important updates and notify you? (Recommended) Yes
This configuration will be written to .env.production
Save configuration? Yes
The script will now dump a tonne of environment variables. Replace the contents of your .env.production file with this list.
# Generated with mastodon:setup on 2023-10-22 09:53:06 UTC
# Some variables in this file will be interpreted differently whether you are
# using docker-compose or not.
LOCAL_DOMAIN=mastodon.localhost.com
SINGLE_USER_MODE=true
SECRET_KEY_BASE=my_key
OTP_SECRET=my_secret
VAPID_PRIVATE_KEY=my_key
VAPID_PUBLIC_KEY=my_key
DB_HOST=db
DB_PORT=5432
DB_NAME=mastodon
DB_USER=mastodon
DB_PASS=6e8f157711eac1b0d5907365
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
S3_ENABLED=true
S3_PROTOCOL=https
S3_BUCKET=mastodon-cdn
S3_REGION=auto
S3_HOSTNAME=myid.r2.cloudflarestorage.com/mastodon-cdn
AWS_ACCESS_KEY_ID=myid
AWS_SECRET_ACCESS_KEY=mykey
S3_ALIAS_HOST=mastodon-cdn.mydomain.com
SMTP_SERVER=smtp.api.createsend.com
SMTP_PORT=587
SMTP_LOGIN=my_username
SMTP_PASSWORD=my_password
SMTP_AUTH_METHOD=plain
SMTP_OPENSSL_VERIFY_MODE=none
SMTP_ENABLE_STARTTLS=auto
SMTP_FROM_ADDRESS=Mastodon <mastodon@mydomain.com>
A couple of things to note here:
- I’m using mastodon.localhost.com domain. Yours will probably be different – we’ll set this up in Apache or Nginx later.
- If you’re using Cloudflare R2 for file hosting like me, the vars above won’t be enough. Also add this line to your .env.production file:
S3_ENDPOINT=https://myid.r2.cloudflarestorage.com/
You’ll then be asked to set up the admin user and given its auto-generated password:
Do you want to create an admin user straight away? Yes
Username: mypolice
E-mail: my@email.com
You can login with the password: mypassword
You can change your password once you login.
Setup will then complete and exit. If you got something wrong and would like to retry, add the following line to your .env.production then rerun the setup script:
# Don't add this unless your initial setup failed
DISABLE_DATABASE_ENVIRONMENT_CHECK=1
Make sure to choose Yes when being offered to destroy the database or to avoid constraint violation errors.
Running the Instance
The only two docker commands you really need to know:
# Run the instance
docker-compose up -d
# Stop the instance
docker-compose down
When running, the web container will be listening on the loopback interface on port 3000 and streaming on port 4000.
Apache Setup
Here’s the apache config to work with your new instance. Remember to update the paths as necessary.
<VirtualHost *:443>
ServerAdmin webmaster@localhost
ServerName mastodon.localhost.com
ServerAlias mastodon.localhost.com
ProxyPreserveHost On
ProxyPass /api/v1/streaming http://localhost:4000/
ProxyPass / http://localhost:3000/
ProxyPassReverse / http://localhost:3000/
RequestHeader set X-Forwarded-Proto "https"
ErrorLog /var/www/mastodon/logs/error.log
LogLevel warn
CustomLog /var/www/mastodon/logs/access.log common
SSLEngine on
SSLProtocol ALL -SSLv2 -SSLv3
SSLHonorCipherOrder On
SSLCipherSuite ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS
SSLCertificateFile "/path/to/certs/localhost.crt"
SSLCertificateKeyFile "/path/to/certs/localhost.key"
</VirtualHost>
Make sure you have the following modules enabled: mod_proxy.so, mod_proxy_http.so, mod_proxy_balancer.so, mod_slotmem_shm.so.
Nginx Config
Or if you’re using Nginx, here’s the config for that instead:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
root /var/www/mastodon;
access_log /var/www/mastodon/logs/access_log;
error_log /var/www/mastodon/logs/error_log;
index index.html index.htm;
server_name mastodon.localhost.com;
keepalive_timeout 70;
sendfile on;
client_max_body_size 80m;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
add_header Strict-Transport-Security "max-age=31536000";
location / {
try_files $uri @proxy;
}
location ~ ^/(emoji|packs|system/accounts/avatars|system/media_attachments/files) {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri @proxy;
}
location @proxy {
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 https;
proxy_set_header Proxy "";
proxy_pass_header Server;
proxy_pass http://127.0.0.1:3000;
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 /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 https;
proxy_set_header Proxy "";
proxy_pass http://127.0.0.1:4000;
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;
}
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/mastodon.localhost.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/mastodon.localhost.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = mastodon.localhost.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name mastodon.localhost.com;
return 404; # managed by Certbot
}
Conclusion
This process could definitely be streamlined. There are obvious pitfalls and figuring all this out took a while. Once it’s running though it feels very stable.
Many thanks to Ben Tasker for his excellent writeup as well as this TrillCyborn gist.