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
1 2 3 4 | 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
1 2 | 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | 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.
1 2 3 4 5 6 | 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:
1 | mkdir postgres14 |
Now fire up the container remembering to replace the vars were necessary:
1 2 3 4 5 6 7 8 | #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:
1 | docker exec -it postgres psql -U postgres |
1 2 | 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.
1 | 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.
1 2 3 4 5 6 7 8 9 10 | 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:
1 2 | mkdir -p public/system mkdir redis |
Start the setup process:
1 | 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:
1 | 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:
1 2 | # 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:
1 2 3 4 5 | # 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | 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.