Take advantage of Docker to install Mastodon.

Beware, this blog post is now obsolete.

This article is based on the official docker compose which can be found the in mastodon project repository.

Operating system.

$ lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 22.04 LTS
Release:	22.04
Codename:	jammy

Install and configure firewall

Update package index.

$ sudo apt update

Upgrade packages.

$ sudo apt upgrade

Display network interfaces.

$ ip -br a
lo               UNKNOWN        127.0.0.1/8
enp0s8           UP             192.168.56.4/24 

Install a dynamically managed firewall with support for network zones.

$ sudo apt install firewalld

Service will be enabled and started automatically.

$ sudo systemctl status firewalld.service 
* firewalld.service - firewalld - dynamic firewall daemon
     Loaded: loaded (/lib/systemd/system/firewalld.service; enabled; vendor preset: enabled)
     Active: active (running) since Mon 2022-05-02 02:20:04 UTC; 11s ago
       Docs: man:firewalld(1)
   Main PID: 12924 (firewalld)
      Tasks: 2 (limit: 4665)
     Memory: 23.0M
        CPU: 250ms
     CGroup: /system.slice/firewalld.service
             `-12924 /usr/bin/python3 /usr/sbin/firewalld --nofork --nopid

May 02 02:20:04 ubuntu-jammy systemd[1]: Starting firewalld - dynamic firewall daemon...
May 02 02:20:04 ubuntu-jammy systemd[1]: Started firewalld - dynamic firewall daemon.

Add an external interfaces to the public zone.

$ sudo firewall-cmd --add-interface enp0s8 --zone public
success

Display and verify used zones.

$ sudo firewall-cmd --get-active-zones 
public
  interfaces: enp0s8

Display and verify allowed services.

$ sudo firewall-cmd --list-services --zone=public 
dhcpv6-client ssh

Enable http and https services.

$ sudo firewall-cmd --add-service http --add-service https --zone public
success

Display and verify public zone.

$ sudo firewall-cmd --list-all --zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: enp0s8
  sources: 
  services: dhcpv6-client http https ssh
  ports: 
  protocols: 
  forward: yes
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules:

Ensure that docker interface is in a trusted zone.

$ sudo firewall-cmd --add-interface=docker0 --zone trusted
success

Display and verify trusted zone.

$ sudo firewall-cmd --list-all --zone=trusted
trusted (active)
  target: ACCEPT
  icmp-block-inversion: no
  interfaces: docker0
  sources: 
  services: 
  ports: 
  protocols: 
  forward: yes
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules: 

Add masquerade, as application will contact instances.

$ sudo firewall-cmd --zone=public --add-masquerade

Display and verify public zone.

$ sudo firewall-cmd --list-all --zone=public 
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: enp0s8
  sources: 
  services: dhcpv6-client http https ssh
  ports: 
  protocols: 
  forward: yes
  masquerade: yes
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules: 

Make changes permanent.

$ sudo firewall-cmd --runtime-to-permanent
success

Set server hostname

Define server hostname.

$ sudo hostnamectl --static set-hostname example.org

Increase the limit of the mmap counts

The default limit of the mmap counts is definitely too low for Elasticsearch service.

$ sysctl vm.max_map_count
vm.max_map_count = 65530

Increase it to avoid running out of map areas.

$ echo "vm.max_map_count=262144" | sudo tee /etc/sysctl.d/90-max_map_count.conf
$ sudo sysctl --system
* Applying /etc/sysctl.d/10-console-messages.conf ...
kernel.printk = 4 4 1 7
* Applying /etc/sysctl.d/10-ipv6-privacy.conf ...
net.ipv6.conf.all.use_tempaddr = 2
net.ipv6.conf.default.use_tempaddr = 2
* Applying /etc/sysctl.d/10-kernel-hardening.conf ...
sysctl: setting key "kernel.kptr_restrict": No such file or directory
* Applying /etc/sysctl.d/10-link-restrictions.conf ...
sysctl: setting key "fs.protected_hardlinks": No such file or directory
sysctl: setting key "fs.protected_symlinks": No such file or directory
* Applying /etc/sysctl.d/10-magic-sysrq.conf ...
sysctl: setting key "kernel.sysrq": No such file or directory
* Applying /etc/sysctl.d/10-network-security.conf ...
net.ipv4.conf.default.rp_filter = 2
net.ipv4.conf.all.rp_filter = 2
* Applying /etc/sysctl.d/10-ptrace.conf ...
sysctl: setting key "kernel.yama.ptrace_scope": No such file or directory
* Applying /etc/sysctl.d/10-zeropage.conf ...
sysctl: setting key "vm.mmap_min_addr": Operation not permitted
* Applying /usr/lib/sysctl.d/50-default.conf ...
net.ipv4.conf.default.promote_secondaries = 1
sysctl: setting key "net.ipv4.conf.all.promote_secondaries": Invalid argument
net.ipv4.ping_group_range = 0 2147483647
* Applying /usr/lib/sysctl.d/50-pid-max.conf ...
kernel.pid_max = 4194304
* Applying /etc/sysctl.d/90-max_map_count.conf ...
sysctl: setting key "vm.max_map_count": No such file or directory
* Applying /etc/sysctl.d/99-sysctl.conf ...
* Applying /usr/lib/sysctl.d/protect-links.conf ...
sysctl: setting key "fs.protected_hardlinks": No such file or directory
sysctl: setting key "fs.protected_symlinks": No such file or directory
* Applying /etc/sysctl.conf ...

Install Docker

Install docker.

$ sudo apt install docker.io docker-compose

It will try to start, but it doesn’t play well with recent firewalld using nftables.

$ systemctl status docker
x docker.service - Docker Application Container Engine
     Loaded: loaded (/lib/systemd/system/docker.service; enabled; vendor preset: enabled)
     Active: failed (Result: exit-code) since Mon 2022-05-02 02:14:40 UTC; 6s ago
TriggeredBy: x docker.socket
       Docs: https://docs.docker.com
    Process: 2452 ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock (code=exited, status=1/FAILURE)
   Main PID: 2452 (code=exited, status=1/FAILURE)
        CPU: 59ms
$ sudo iptables -L -v -n
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain FORWARD (policy DROP 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 ACCEPT     all  --  docker0 !docker0  0.0.0.0/0            0.0.0.0/0           
    0     0 ACCEPT     all  --  docker0 docker0  0.0.0.0/0            0.0.0.0/0           
    0     0 DOCKER-USER  all  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain DOCKER (0 references)
 pkts bytes target     prot opt in     out     source               destination         

Chain DOCKER-ISOLATION-STAGE-1 (0 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain DOCKER-ISOLATION-STAGE-2 (0 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain DOCKER-USER (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0  

Ensure that you are part of the docker group.

$ sudo usermod -a -G docker $USER

Alter docker configuration to log messages to journald.

$ cat <<EOF | sudo tee /etc/docker/daemon.json
{
  "iptables": false,
  "log-driver": "journald"
}
EOF

The best solution is to reboot operating system.

$ sudo reboot

Service will start normally.

$ sudo systemctl status docker
* docker.service - Docker Application Container Engine
     Loaded: loaded (/lib/systemd/system/docker.service; enabled; vendor preset: enabled)
     Active: active (running) since Mon 2022-05-02 02:20:48 UTC; 2min 27s ago
TriggeredBy: * docker.socket
       Docs: https://docs.docker.com
   Main PID: 981 (dockerd)
      Tasks: 8
     Memory: 97.8M
        CPU: 151ms
     CGroup: /system.slice/docker.service
             `-981 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock

May 02 02:20:48 example.org dockerd[981]: time="2022-05-02T02:20:48.187805266Z" level=info msg="ccResolverWrapper: sending update to cc: {[{unix:///run/containerd/cont>
May 02 02:20:48 example.org dockerd[981]: time="2022-05-02T02:20:48.187891761Z" level=info msg="ClientConn switching balancer to \"pick_first\"" module=grpc
May 02 02:20:48 example.org dockerd[981]: time="2022-05-02T02:20:48.224420439Z" level=info msg="[graphdriver] using prior storage driver: overlay2"
May 02 02:20:48 example.org dockerd[981]: time="2022-05-02T02:20:48.227674497Z" level=info msg="Loading containers: start."
May 02 02:20:48 example.org dockerd[981]: time="2022-05-02T02:20:48.247689532Z" level=info msg="Default bridge (docker0) is assigned with an IP address 172.17.0.0/16. >
May 02 02:20:48 example.org dockerd[981]: time="2022-05-02T02:20:48.264692171Z" level=info msg="Loading containers: done."
May 02 02:20:48 example.org dockerd[981]: time="2022-05-02T02:20:48.311939073Z" level=info msg="Docker daemon" commit=20.10.12-0ubuntu4 graphdriver(s)=overlay2 version>
May 02 02:20:48 example.org dockerd[981]: time="2022-05-02T02:20:48.312291592Z" level=info msg="Daemon has completed initialization"
May 02 02:20:48 example.org systemd[1]: Started Docker Application Container Engine.
May 02 02:20:48 example.org dockerd[981]: time="2022-05-02T02:20:48.342171039Z" level=info msg="API listen on /run/docker.sock"
$ sudo iptables -L -v -n
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination 

Everything is as expected as we are using firewalld/nftables. Alternatively, you could alter firewalld configuration to use iptables.

Create application definition

Create application directory.

$ sudo mkdir /opt/mastodon/

Create directories related to database operations.

$ sudo mkdir -p /opt/mastodon/database/{postgresql,redis,elasticsearch}

Create directories related to web operations.

$ sudo mkdir -p /opt/mastodon/web/{public,system}

Fix permissions on web directories.

$ sudo chown 991:991 /opt/mastodon/web/{public,system}

Fix permissions on elasticsearch directory.

$ sudo chown 1000 /opt/mastodon/database/elasticsearch

Create a docker-compose file.

$ cat << EOF | sudo tee /opt/mastodon/docker-compose.yml
version: '3'

services:
  postgresql:
    image: postgres:14
    env_file: database.env
    restart: always
    shm_size: 256mb
    healthcheck:
      test: ['CMD', 'pg_isready', '-U', 'postgres']
    volumes:
      - postgresql:/var/lib/postgresql/data
    networks:
      - internal_network

#  pgbouncer:
#    image: edoburu/pgbouncer:1.12.0
#    env_file: database.env
#    depends_on:
#      - postgresql
#    healthcheck:
#      test: ['CMD', 'pg_isready', '-h', 'localhost']
#    networks:
#      - internal_network

  redis:
    image: redis:7
    restart: always
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
    volumes:
      - redis:/data
    networks:
      - internal_network

  redis-volatile:
    image: redis:7
    restart: always
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
    networks:
      - internal_network

  elasticsearch:
    image: elasticsearch:7.17.3
    restart: always
    env_file: database.env
    environment:
      - cluster.name=elasticsearch-mastodon
      - discovery.type=single-node
      - bootstrap.memory_lock=true
      - xpack.security.enabled=true
      - ingest.geoip.downloader.enabled=false
    ulimits:
      memlock:
        soft: -1
        hard: -1
    healthcheck:
      test: ["CMD-SHELL", "nc -z elasticsearch 9200"]
    volumes:
      - elasticsearch:/usr/share/elasticsearch/data
    networks:
      - internal_network

  website:
    image: tootsuite/mastodon:v4.0.2
    env_file: 
      - application.env
      - database.env
    command: bash -c "bundle exec rails s -p 3000"
    restart: always    
    depends_on:
      - postgresql
#      - pgbouncer
      - redis
      - redis-volatile
      - elasticsearch
    ports:
      - '127.0.0.1:3000:3000'
    networks:
      - internal_network
      - external_network
    healthcheck:
      test: ['CMD-SHELL', 'wget -q --spider --proxy=off localhost:3000/health || exit 1']
    volumes:
      - uploads:/mastodon/public/system

  shell:
    image: tootsuite/mastodon:v4.0.2
    env_file: 
      - application.env
      - database.env
    command: /bin/bash 
    restart: "no"
    networks:
      - internal_network
      - external_network
    volumes:
      - uploads:/mastodon/public/system

  streaming:
    image: tootsuite/mastodon:v4.0.2
    env_file: 
      - application.env
      - database.env
    command: node ./streaming
    restart: always
    depends_on:
      - postgresql
#      - pgbouncer
      - redis
      - redis-volatile
      - elasticsearch
    ports:
      - '127.0.0.1:4000:4000'
    networks:
      - internal_network
      - external_network
    healthcheck:
      test: ['CMD-SHELL', 'wget -q --spider --proxy=off localhost:4000/api/v1/streaming/health || exit 1']

  sidekiq:
    image: tootsuite/mastodon:v4.0.2
    env_file: 
      - application.env
      - database.env
    command: bundle exec sidekiq
    restart: always
    depends_on:
      - postgresql
#      - pgbouncer
      - redis
      - redis-volatile
      - website
    networks:
      - internal_network
      - external_network
    healthcheck:
      test: ['CMD-SHELL', "ps aux | grep '[s]idekiq\ 6' || false"]
    volumes:
      - uploads:/mastodon/public/system

networks:
  external_network:
  internal_network:
    internal: true

volumes:
  postgresql:
    driver_opts:
      type: none
      device: /opt/mastodon/database/postgresql
      o: bind    
  redis:
    driver_opts:
      type: none
      device: /opt/mastodon/database/redis
      o: bind    
  elasticsearch:
    driver_opts:
      type: none
      device: /opt/mastodon/database/elasticsearch
      o: bind    
  uploads:
    driver_opts:
      type: none
      device: /opt/mastodon/web/system
      o: bind    
EOF

Generate secrets

Initialize empty application configuration as you can generate secrets using docker container.

$ sudo touch /opt/mastodon/application.env
$ sudo touch /opt/mastodon/database.env

Generate SECRET_KEY_BASE and OTP_SECRET.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell bundle exec rake secret
Creating mastodon_shell_run ... done
0701fa876663c4e4111953c251b5794dba8e1aac8f120fac8b674b5142bee8802ade6eb91266dc1df82e23399dc77e27c1c2e6b7b3e8f0ba2bce9f65064a5791

Alternatively use openssl utility.

$ openssl rand -hex 64
0309a599e8ef767d9aa0860a57afca3287ef79e18d1ed2c72be833f4c2c3191825acda9b906be5e76ebc7accb21d7eabb49ba11ead5bfa051d5e54fc8ef7ea0

Generate VAPID_PRIVATE_KEY and VAPID_PRIVATE_KEY.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell bundle exec rake mastodon:webpush:generate_vapid_key 
Creating mastodon_shell_run ... done
VAPID_PRIVATE_KEY=kKz6fGDFsSEV-q3IZh3dMCmsi8YyLTh7NSCxrxBT4dU=
VAPID_PUBLIC_KEY=BMf1qY_YSybedtBsksFkSTkW_GcZ76BJcEJyHx9UO1Ig9NML_9_vdZwAt-vf8POV7e5R6J17MbUZ-wXbdU9NQ_k=

Alternatively use openssl utility.

$ openssl ecparam -name prime256v1 -genkey -noout -out vapid_private_key.pem
$ openssl ec -in vapid_private_key.pem -pubout -out vapid_public_key.pem
read EC key
writing EC key
$ cat vapid_private_key.pem 
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEINS+CzUMTQChTeRDSAv9ESg4WUsA4IDW+7ASWnioOF4FoAoGCCqGSM49
AwEHoUQDQgAEKKxVlTTf+ctC8OA2Sh9qY5AQtLd7O/NDIVevLoyEuLUojUXf8Szn
dela0hk6DSOxshPcPWK5AE64e/doIZuZUg==
-----END EC PRIVATE KEY-----
$ cat vapid_public_key.pem 
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKKxVlTTf+ctC8OA2Sh9qY5AQtLd7
O/NDIVevLoyEuLUojUXf8Szndela0hk6DSOxshPcPWK5AE64e/doIZuZUg==
-----END PUBLIC KEY-----
$ echo -n VAPID_PRIVATE_KEY=;cat vapid_private_key.pem | sed -e "1 d" -e "$ d" | tr -d "\n"; echo
VAPID_PRIVATE_KEY=MHcCAQEEINS+CzUMTQChTeRDSAv9ESg4WUsA4IDW+7ASWnioOF4FoAoGCCqGSM49AwEHoUQDQgAEKKxVlTTf+ctC8OA2Sh9qY5AQtLd7O/NDIVevLoyEuLUojUXf8Szndela0hk6DSOxshPcPWK5AE64e/doIZuZUg==
$ echo -n VAPID_PUBLIC_KEY=;cat vapid_public_key.pem | sed -e "1 d" -e "$ d" | tr -d "\n"; echo
VAPID_PUBLIC_KEY=MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKKxVlTTf+ctC8OA2Sh9qY5AQtLd7O/NDIVevLoyEuLUojUXf8Szndela0hk6DSOxshPcPWK5AE64e/doIZuZUg==

Store these secrets securely and remember that changing these will break push notifications.

I have used openssl utility to generate postgresql and elasticsearch passwords.

$ openssl rand -hex 15
96a0a9a3720e6718f105619a5cc6ce

Perform initial configuration

Create database configuration file.

$ cat << EOF | sudo tee /opt/mastodon/database.env
# postgresql configuration
POSTGRES_USER=mastodon
POSTGRES_DB=mastodon_production
POSTGRES_PASSWORD=O6lOD6nF2LbhhJs1e7QL

# pgbouncer configuration
#POOL_MODE=transaction
#ADMIN_USERS=postgres,mastodon
#DATABASE_URL="postgres://mastodon:O6lOD6nF2LbhhJs1e7QL@postgresql:5432/mastodon_production"

# elasticsearch
ES_JAVA_OPTS=-Xms512m -Xmx512m
ELASTIC_PASSWORD=gpwETw6U875pbhnPxbo4

# mastodon database configuration
#DB_HOST=pgbouncer
DB_HOST=postgresql
DB_USER=mastodon
DB_NAME=mastodon_production
DB_PASS=O6lOD6nF2LbhhJs1e7QL
DB_PORT=5432

REDIS_HOST=redis
REDIS_PORT=6379

CACHE_REDIS_HOST=redis-volatile
CACHE_REDIS_PORT=6379

ES_ENABLED=true
ES_HOST=elasticsearch
ES_PORT=9200
ES_USER=elastic
ES_PASS=gpwETw6U875pbhnPxbo4
EOF

Create application configuration file.

$ cat << EOF | sudo tee /opt/mastodon/application.env
# environment
RAILS_ENV=production
NODE_ENV=production

# domain
LOCAL_DOMAIN=example.org

# redirect to the first profile
SINGLE_USER_MODE=true

# do not serve static files
RAILS_SERVE_STATIC_FILES=false

# concurrency
WEB_CONCURRENCY=2
MAX_THREADS=5

# pgbouncer
#PREPARED_STATEMENTS=false

# locale
DEFAULT_LOCALE=en

# email, not used
SMTP_SERVER=localhost
SMTP_PORT=587
SMTP_FROM_ADDRESS=notifications@example.org

# secrets
SECRET_KEY_BASE=4fab322678d6baccaacc70a83937c883fb1ca7a30cafc826970e03b011b77d29377ae2c87b613dc234c083ccc63390847e9029aa9e2baf434e85352cbede616d
OTP_SECRET=58b2814d5692263875d072d1a6d873fc822d1be034ebd2402850bf2915c36d46d3ed35f00d6fa9a102f5d7e30586d6a163414eb8d0d72f8393adc9667f9c8af9

VAPID_PRIVATE_KEY=kKz6fGDFsSEV-q3IZh3dMCmsi8YyLTh7NSCxrxBT4dU=
VAPID_PUBLIC_KEY=BMf1qY_YSybedtBsksFkSTkW_GcZ76BJcEJyHx9UO1Ig9NML_9_vdZwAt-vf8POV7e5R6J17MbUZ-wXbdU9NQ_k=
EOF

Secure these files.

$ sudo chmod 600 /opt/mastodon/application.env 
$ sudo chmod 600 /opt/mastodon/database.env 

Extract static files

Create a temporary volume pointing to /opt/mastodon/web/public directory.

$ sudo docker volume create --opt type=none --opt device=/opt/mastodon/web/public --opt o=bind temporary_static

Copy static files.

$ sudo docker run --rm -v "temporary_static:/static" tootsuite/mastodon:v4.0.2 bash -c "cp -r /opt/mastodon/public/* /static/"

Remove temporary volume.

$ sudo docker volume rm temporary_static

That way we can serve these files using nginx.

$ ls /opt/mastodon/web/public/
500.html                    avatars            emoji            inert.css           oops.gif    shortcuts                 web-push-icon_favourite.png
android-chrome-192x192.png  badge.png          favicon-dev.ico  mask-icon.svg       oops.png    sounds                    web-push-icon_reblog.png
apple-touch-icon.png        browserconfig.xml  favicon.ico      mstile-150x150.png  packs       sw.js
assets                      embed.js           headers          ocr                 robots.txt  web-push-icon_expand.png

Delete and copy static files after every mastodon container upgrade.

Install web-server

Install nginx web-server.

$ sudo apt install nginx

Disable default virtual host.

$ sudo unlink /etc/nginx/sites-enabled/default

Create a directory for SSL certificates.

$ sudo mkdir /etc/nginx/ssl

Copy or create a example.org.crt certificate and example.org.key private key.

$ sudo openssl req -subj "/commonName=example.org/" -x509 -nodes -days 730 -newkey rsa:2048 -keyout /etc/nginx/ssl/example.org.key -out /etc/nginx/ssl/example.org.crt

Create a virtual host.

$ cat << 'EOF' | sudo tee /etc/nginx/sites-available/mastodon
map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

upstream backend {
    server 127.0.0.1:3000 fail_timeout=0;
}

upstream streaming {
    server 127.0.0.1:4000 fail_timeout=0;
}

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

server {
  listen 80;
  server_name example.org;
  location / { return 301 https://$host$request_uri; }
}

server {
  listen 443 ssl http2;
  server_name example.org;

  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ciphers HIGH:!MEDIUM:!LOW:!aNULL:!NULL:!SHA;
  ssl_prefer_server_ciphers on;
  ssl_session_cache shared:SSL:10m;
  ssl_session_tickets off;

  ssl_certificate     /etc/nginx/ssl/example.org.crt;
  ssl_certificate_key /etc/nginx/ssl/example.org.key;

  keepalive_timeout    70;
  sendfile             on;
  client_max_body_size 80m;

  root /opt/mastodon/web/public;

  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 image/svg+xml image/x-icon;

  add_header Strict-Transport-Security "max-age=31536000" always;

  location / {
    try_files $uri @proxy;
  }

  # iOS
  proxy_force_ranges on;

  location ~ ^/(system/accounts/avatars|system/media_attachments/files) {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Strict-Transport-Security "max-age=31536000" always;
    root /opt/mastodon/;
    try_files $uri @proxy;
  }

  location ~ ^/(emoji|packs) {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Strict-Transport-Security "max-age=31536000" always;
    try_files $uri @proxy;
  }

  location /sw.js {
    add_header Cache-Control "public, max-age=0";
    add_header Strict-Transport-Security "max-age=31536000" always;
    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 $scheme;
    proxy_set_header Proxy "";
    proxy_pass_header Server;

    proxy_pass http://backend;
    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;
    add_header Strict-Transport-Security "max-age=31536000" always;

    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 $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;
  }

  error_page 500 501 502 503 504 /500.html;
}
EOF

Enable virtual host.

$ sudo ln -s /etc/nginx/sites-available/mastodon /etc/nginx/sites-enabled/

Restart web server.

$ sudo systemctl restart nginx

Preferably you should ensure that nginx is stopped and start it after adding initial user and disabling registrations if that is what you want.

Start mastodon service

Pull images first.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml pull
Pulling postgresql     ... done
Pulling redis          ... done
Pulling redis-volatile ... done
Pulling elasticsearch  ... done
Pulling website        ... done
Pulling shell          ... done
Pulling streaming      ... done
Pulling sidekiq        ... done

Create mastodon service file.

$ cat << EOF | sudo tee /etc/systemd/system/mastodon.service
[Unit]
Description=Mastodon service
After=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes

WorkingDirectory=/opt/mastodon
ExecStart=/usr/bin/docker-compose -f /opt/mastodon/docker-compose.yml up -d
ExecStop=/usr/bin/docker-compose -f /opt/mastodon/docker-compose.yml down

[Install]
WantedBy=multi-user.target
EOF
$ sudo systemctl daemon-reload

Start postgresql database and pgbouncer.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml up -d postgresql redis redis-volatile
Creating mastodon_postgresql_1     ... done
Creating mastodon_redis_1          ... done
Creating mastodon_redis-volatile_1 ... done

Wait till database starts.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml ps
Name                         Command                  State       Ports
---------------------------------------------------------------------------------
mastodon_postgresql_1       docker-entrypoint.sh postgres    Up (healthy)        
mastodon_redis-volatile_1   docker-entrypoint.sh redis ...   Up (healthy)        
mastodon_redis_1            docker-entrypoint.sh redis ...   Up (healthy)

Setup database using shell container.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell bundle exec rake db:setup

Next time, after upgrade execute database migrations.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell bundle exec rake db:migrate

Start and enable service.

$ sudo systemctl enable --now mastodon.service

It should be all green after a while.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml ps
Name                         Command                  State                     Ports               
--------------------------------------------------------------------------------------------------------------
mastodon_elasticsearch_1    /bin/tini -- /usr/local/bi ...   Up (healthy)                                     
mastodon_postgresql_1       docker-entrypoint.sh postgres    Up (healthy)                                     
mastodon_redis-volatile_1   docker-entrypoint.sh redis ...   Up (healthy)                                     
mastodon_redis_1            docker-entrypoint.sh redis ...   Up (healthy)                                     
mastodon_shell_1            /usr/bin/tini -- /bin/bash       Exit 0                                           
mastodon_sidekiq_1          /usr/bin/tini -- bundle ex ...   Up (healthy)   3000/tcp, 4000/tcp                
mastodon_streaming_1        /usr/bin/tini -- node ./st ...   Up (healthy)   3000/tcp, 127.0.0.1:4000->4000/tcp
mastodon_website_1          /usr/bin/tini -- bash -c b ...   Up (healthy)   127.0.0.1:3000->3000/tcp, 4000/tcp

Create user.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell bin/tootctl accounts create milosz --email nonexistingmilosz@google.com --confirmed --role Owner
Creating mastodon_shell_run ... done
OK
New password: a2af76138ca9502b1d1e1956aa8727e8

Disable registrations.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell  bin/tootctl settings registrations close
OK
Creating mastodon_shell_run ... done
OK

Create additional jobs

Remove downloaded media files.

$ cat << EOF | sudo tee /etc/systemd/system/mastodon-media-remove.service
[Unit]
Description=Mastodon - media remove service
Wants=mastodon-media-remove.timer

[Service]
Type=oneshot
StandardError=null
StandardOutput=null

WorkingDirectory=/opt/mastodon
ExecStart=/usr/bin/docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell tootctl media remove

[Install]
WantedBy=multi-user.target
EOF
$ cat << EOF | sudo tee /etc/systemd/system/mastodon-media-remove.timer
[Unit]
Description=Schedule a media remove every week

[Timer]
Persistent=true
OnCalendar=Sat *-*-* 00:00:00
Unit=mastodon-media-remove.service

[Install]
WantedBy=timers.target
EOF

Remove preview cards.

$ cat << EOF | sudo tee /etc/systemd/system/mastodon-preview_cards-remove.service
[Unit]
Description=Mastodon - preview cards remove service
Wants=mastodon-preview_cards-remove.timer

[Service]
Type=oneshot
StandardError=null
StandardOutput=null

WorkingDirectory=/opt/mastodon
ExecStart=/usr/bin/docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell tootctl preview_cards remove

[Install]
WantedBy=multi-user.target
EOF
$ cat << EOF | sudo tee /etc/systemd/system/mastodon-preview_cards-remove.timer
[Unit]
Description=Schedule a preview cards remove every week

[Timer]
Persistent=true
OnCalendar=Sat *-*-* 00:00:00
Unit=mastodon-preview_cards-remove.service

[Install]
WantedBy=timers.target
EOF

Create, upgrade and populate Elasticsearch indices.

$ cat << EOF | sudo tee /etc/systemd/system/mastodon-search-deploy.service
[Unit]
Description=Mastodon - update Elasticsearch indices
Wants=mastodon-search-deploy.timer

[Service]
Type=oneshot
StandardError=null
StandardOutput=null

WorkingDirectory=/opt/mastodon
ExecStart=/usr/bin/docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell tootctl search deploy

[Install]
WantedBy=multi-user.target
EOF
$ cat << EOF | sudo tee /etc/systemd/system/mastodon-search-deploy.timer
[Unit]
Description=Schedule a preview cards remove every week

[Timer]
Persistent=true
OnCalendar=Sat *-*-* 03:00:00
Unit=mastodon-search-deploy.service

[Install]
WantedBy=timers.target
EOF

Reload systemd configuration.

$ sudo systemctl daemon-reload

Enable these timers (enable & start).

$ sudo systemctl enable --now mastodon-preview_cards-remove.timer
$ sudo systemctl enable --now mastodon-media-remove.timer
$ sudo systemctl enable --now mastodon-search-deploy.timer

List timers.

$ systemctl list-timers      
NEXT                        LEFT              LAST                        PASSED       UNIT                                ACTIVATES                            
Sun 2022-05-01 03:00:00 UTC 2h 38min left     n/a                         n/a          mastodon-search-deploy.timer        mastodon-search-deploy.service
Sun 2022-05-01 23:23:06 UTC 55min left        Sun 2022-05-01 16:33:47 UTC 5h 54min ago ua-timer.timer                      ua-timer.service
Mon 2022-05-02 00:00:00 UTC 1h 31min left     Sun 2022-05-01 00:00:13 UTC 22h ago      dpkg-db-backup.timer                dpkg-db-backup.service
Mon 2022-05-02 00:00:00 UTC 1h 31min left     Sun 2022-05-01 00:00:13 UTC 22h ago      logrotate.timer                     logrotate.service
Mon 2022-05-02 00:46:08 UTC 2h 18min left     Thu 2022-04-28 19:30:47 UTC 3 days ago   fstrim.timer                        fstrim.service
Mon 2022-05-02 04:36:10 UTC 6h left           Sun 2022-05-01 10:35:05 UTC 11h ago      man-db.timer                        man-db.service
Mon 2022-05-02 06:06:01 UTC 7h left           Sun 2022-05-01 18:31:50 UTC 3h 56min ago fwupd-refresh.timer                 fwupd-refresh.service
Mon 2022-05-02 06:16:36 UTC 7h left           Sun 2022-05-01 16:17:01 UTC 6h ago       motd-news.timer                     motd-news.service
Mon 2022-05-02 06:57:01 UTC 8h left           Sun 2022-05-01 06:02:19 UTC 16h ago      apt-daily-upgrade.timer             apt-daily-upgrade.service
Mon 2022-05-02 08:34:26 UTC 10h left          Sun 2022-05-01 19:00:26 UTC 3h 27min ago apt-daily.timer                     apt-daily.service
Mon 2022-05-02 19:50:21 UTC 21h left          Sun 2022-05-01 19:50:21 UTC 2h 37min ago update-notifier-download.timer      update-notifier-download.service
Mon 2022-05-02 19:59:15 UTC 21h left          Sun 2022-05-01 19:59:15 UTC 2h 28min ago systemd-tmpfiles-clean.timer        systemd-tmpfiles-clean.service
Sat 2022-05-07 00:00:00 UTC 5 days left       n/a                         n/a          mastodon-media-remove.timer         mastodon-media-remove.service
Sat 2022-05-07 00:00:00 UTC 5 days left       n/a                         n/a          mastodon-preview_cards-remove.timer mastodon-preview_cards-remove.service
Sun 2022-05-08 03:10:12 UTC 6 days left       Sun 2022-05-01 03:10:14 UTC 19h ago      e2scrub_all.timer                   e2scrub_all.service
Tue 2022-05-10 14:22:45 UTC 1 week 1 day left Sun 2022-05-01 22:26:16 UTC 1min 45s ago update-notifier-motd.timer          update-notifier-motd.service

Additional notes

Use tootctl utility for basic maintenance tasks.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell tootctl
Creating mastodon_shell_run ... done
Commands:
  tootctl accounts SUBCOMMAND ...ARGS                # Manage accounts
  tootctl cache SUBCOMMAND ...ARGS                   # Manage cache
  tootctl canonical_email_blocks SUBCOMMAND ...ARGS  # Manage canonical e-mail blocks
  tootctl domains SUBCOMMAND ...ARGS                 # Manage account domains
  tootctl email_domain_blocks SUBCOMMAND ...ARGS     # Manage e-mail domain blocks
  tootctl emoji SUBCOMMAND ...ARGS                   # Manage custom emoji
  tootctl feeds SUBCOMMAND ...ARGS                   # Manage feeds
  tootctl help [COMMAND]                             # Describe available commands or one specific command
  tootctl ip_blocks SUBCOMMAND ...ARGS               # Manage IP blocks
  tootctl maintenance SUBCOMMAND ...ARGS             # Various maintenance utilities
  tootctl media SUBCOMMAND ...ARGS                   # Manage media files
  tootctl preview_cards SUBCOMMAND ...ARGS           # Manage preview cards
  tootctl search SUBCOMMAND ...ARGS                  # Manage the search engine
  tootctl self-destruct                              # Erase the server from the federation
  tootctl settings SUBCOMMAND ...ARGS                # Manage dynamic settings
  tootctl statuses SUBCOMMAND ...ARGS                # Manage statuses
  tootctl upgrade SUBCOMMAND ...ARGS                 # Various version upgrade utilities
  tootctl version                                    # Show version

Reset password in case of emergency.

$ sudo docker-compose -f /opt/mastodon/docker-compose.yml run --rm shell tootctl accounts modify milosz --reset-password

Use acme.sh to issue Let’s Encrypt certificate.

NTP is configured by default, but I have skipped parts related to enforcing key based secure shell authentication as this article is already too long.

$ timedatectl 
Local time: Mon 2022-05-02 01:37:38 UTC
           Universal time: Mon 2022-05-02 01:37:38 UTC
                 RTC time: Mon 2022-05-02 01:37:37
                Time zone: Etc/UTC (UTC, +0000)
System clock synchronized: yes
              NTP service: active
          RTC in local TZ: no

Look at the OpenSSL::PKey::RSAError on search #18106 if on Mastodon 3.5.1 you get 503 HTTP error when searching for people after fresh install.

Follow up

Sending VAPID identified WebPush Notifications via Mozilla’s Push Service

Mastodon Is Like Twitter Without Nazis, So Why Are We Not Using It?

UNTESTED: Adding pgbouncer to docker-compose stack. #18208

ko-fi