0

Running a Mastodon Instance with Docker

Posted (Updated ) in Uncategorized

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.