Black Cat Blog

Thoughts, Stories, and Ideas

Mastodon On Podman

Today I am really proud of myself for having gotten Mastodon operational on Podman. I wanted a challenge and I wanted to get off of Docker because I don’t like Docker’s attitude towards open source. Furthermore, Docker is not as secure as Podman because Podman runs rootless by default. Below is how I got it working.

The first step is to create a new pod for the instance to live on. You do this by typing the following command: podman pod create --name mastodon --publish 3000:3000 --publish 4000:4000. This command not only creates the container, but sets the entry points for the web and streaming services.

Now it’s time to pull the images:

podman pull ghcr.io/mastodon/mastodon
podman pull elasticsearch:7.17.22
podman pull redis
podman pull postgres:14-alpine

After the images have been downloaded, we have to create some directories and assign privileges.

mkdir postgres14/ redis/ elasticsearch/
mkdir -p public/system
chmod go+w elasticsearch/
chmod go+w public/system

Now we have to get Mastodon set up. The first step is getting PostgreSQL and Redis started.

podman run -d --replace --name db --pod mastodon --shm-size=256m -e 'POSTGRES_HOST_AUTH_METHOD=trust' -v /path/to/postgres14:/var/lib/postgresql/data:U,Z postgres:14-alpine

podman run -d --replace --name redis --pod mastodon -v /path/to/redis:/data:U,Z redis

Doing a podman ps should give you something similar to the following. We just have to make certain that Redis and PostgreSQL have started.

CONTAINER ID  IMAGE                                         COMMAND               CREATED         STATUS         PORTS                                           NAMES
76d341058d9b  localhost/podman-pause:4.9.4-rhel-1721813252                        17 minutes ago  Up 17 minutes  0.0.0.0:3000->3000/tcp, 0.0.0.0:4000->4000/tcp  2406be400633-infra
302417fee1c7  docker.io/library/postgres:14-alpine          postgres              17 minutes ago  Up 17 minutes  0.0.0.0:3000->3000/tcp, 0.0.0.0:4000->4000/tcp  db
e5850a4e9bd8  docker.io/library/redis:latest                redis-server          17 minutes ago  Up 17 minutes  0.0.0.0:3000->3000/tcp, 0.0.0.0:4000->4000/tcp  redis

Since Mastodon needs Redis and PostgreSQL running in order to complete the setup, we can now run the setup wizard. For now just create a dummy .env.production file. The wizard will create the actual one for you to copy and paste into. When prompted to create an admin user, select no for now.

touch .env.production

podman run -it --replace --rm --name web --pod mastodon --env-file=./.env.production --requires db --requires redis -v /home/matt/live/public/system:/mastodon/public/system:U,Z mastodon bundle exec rake mastodon:setup

Once the wizard has completed, make certain you copy and paste the environment settings into .env.production and add the following lines to the bottom. This will enable ElasticSearch, a neat feature for enhanced searching of posts.

ES_ENABLED=true
ES_HOST=es
ES_PORT=9200

Now, let’s start the Mastodon services.

podman run -d -it --replace --name web --pod mastodon --env-file=/path/to/.env.production --requires db --requires redis -v /path/to/public/system:/mastodon/public/system:U,Z mastodon bundle exec puma -C config/puma.rb

podman run -d -it --replace --name streaming --pod mastodon --requires db --requires redis --env-file=/path/to/.env.production mastodon node ./streaming

podman run -d -it --replace --name sidekiq --pod mastodon --env-file=/path/to/.env.production --requires db --requires redis -v /path/to/public/system:/mastodon/public/system:U,Z mastodon bundle exec sidekiq

podman run -d -it --replace --name es --pod mastodon -e ES_JAVA_OPTS=-"Xms512m -Xmx512m -Des.enforce.bootstrap.checks=true" -e xpack.license.self_generated.type=basic -e xpack.security.enabled=false -e xpack.watcher.enabled=false -e xpack.graph.enabled=false -e xpack.ml.enabled=false -e bootstrap.memory_lock=false -e cluster.name=es-mastodon -e discovery.type=single-node -e thread_pool.write.queue_size=1000 -v /path/to/elasticsearch:/usr/share/elasticsearch/data:Z --ulimit memlock=-1:-1 --ulimit nofile=65536:65536 elasticsearch:7.17.22

If all goes well, you should see something similar to the following when you type podman ps. You want all the containers running at this point.

CONTAINER ID  IMAGE                                         COMMAND               CREATED         STATUS         PORTS                                           NAMES
76d341058d9b  localhost/podman-pause:4.9.4-rhel-1721813252                        38 minutes ago  Up 38 minutes  0.0.0.0:3000->3000/tcp, 0.0.0.0:4000->4000/tcp  2406be400633-infra
302417fee1c7  docker.io/library/postgres:14-alpine          postgres              38 minutes ago  Up 38 minutes  0.0.0.0:3000->3000/tcp, 0.0.0.0:4000->4000/tcp  db
3f5bd84ae893  ghcr.io/mastodon/mastodon:latest              bundle exec sidek...  38 minutes ago  Up 38 minutes  0.0.0.0:3000->3000/tcp, 0.0.0.0:4000->4000/tcp  sidekiq
e5850a4e9bd8  docker.io/library/redis:latest                redis-server          38 minutes ago  Up 38 minutes  0.0.0.0:3000->3000/tcp, 0.0.0.0:4000->4000/tcp  redis
c94e215609ba  docker.io/library/elasticsearch:7.17.22       eswrapper             38 minutes ago  Up 38 minutes  0.0.0.0:3000->3000/tcp, 0.0.0.0:4000->4000/tcp  es
e115e57adb4f  ghcr.io/mastodon/mastodon:latest              node ./streaming      38 minutes ago  Up 38 minutes  0.0.0.0:3000->3000/tcp, 0.0.0.0:4000->4000/tcp  streaming
e1d7870ed594  ghcr.io/mastodon/mastodon:latest              bundle exec puma ...  38 minutes ago  Up 38 minutes  0.0.0.0:3000->3000/tcp, 0.0.0.0:4000->4000/tcp  web

Once you’ve verified that all containers are running, it’s time to create an administrative user. Note the password that you are given and put it someplace safe for now.

podman exec -it web tootctl accounts create <username> --email <email> --confirmed --role Owner

podman exec -it web tootctl accounts approve <username>

The next step is to install nginx and use the following configuation. Setting up nginx is outside of the scope of this post but this configuration is known working.

map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

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

upstream streaming {
    # Instruct nginx to send connections to the server with the least number of connections
    # to ensure load is distributed evenly.
    least_conn;

    server 127.0.0.1:4000 fail_timeout=0;
    # Uncomment these lines for load-balancing multiple instances of streaming for scaling,
    # this assumes your running the streaming server on ports 4000, 4001, and 4002:
    # server 127.0.0.1:4001 fail_timeout=0;
    # server 127.0.0.1:4002 fail_timeout=0;
}

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

#server {
#  listen 80;
#  listen [::]:80;
#  server_name example.com;
#  root /home/mastodon/live/public;
#  location /.well-known/acme-challenge/ { allow all; }
#  location / { return 301 https://$host$request_uri; }
#}

server {
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  server_name goblackcat.social;

  ssl_protocols TLSv1.2 TLSv1.3;

  # You can use https://ssl-config.mozilla.org/ to generate your cipher set.
  # We recommend their "Intermediate" level.
  ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;

  ssl_prefer_server_ciphers on;
  ssl_session_cache shared:SSL:10m;
  ssl_session_tickets off;

  # Uncomment these lines once you acquire a certificate:
  ssl_certificate     /etc/letsencrypt/live/goblackcat.social/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/goblackcat.social/privkey.pem;

  keepalive_timeout    70;
  sendfile             on;
  client_max_body_size 99m;

  root /home/mastodon/live/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;

  location / {
    try_files $uri @proxy;
  }

  # If Docker is used for deployment and Rails serves static files,
  # then needed must replace line `try_files $uri =404;` with `try_files $uri @proxy;`.
  location = /sw.js {
    add_header Cache-Control "public, max-age=604800, must-revalidate";
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
    try_files $uri @proxy;
  }

  location ~ ^/assets/ {
    add_header Cache-Control "public, max-age=2419200, must-revalidate";
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
    try_files $uri @proxy;
  }

  location ~ ^/avatars/ {
    add_header Cache-Control "public, max-age=2419200, must-revalidate";
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
    try_files $uri @proxy;
  }

  location ~ ^/emoji/ {
    add_header Cache-Control "public, max-age=2419200, must-revalidate";
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
    try_files $uri @proxy;
  }

  location ~ ^/headers/ {
    add_header Cache-Control "public, max-age=2419200, must-revalidate";
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
    try_files $uri @proxy;
  }

  location ~ ^/packs/ {
    add_header Cache-Control "public, max-age=2419200, must-revalidate";
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
    try_files $uri @proxy;
  }

  location ~ ^/shortcuts/ {
    add_header Cache-Control "public, max-age=2419200, must-revalidate";
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
    try_files $uri @proxy;
  }

  location ~ ^/sounds/ {
    add_header Cache-Control "public, max-age=2419200, must-revalidate";
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
    try_files $uri @proxy;
  }

  location ~ ^/system/ {
    add_header Cache-Control "public, max-age=2419200, immutable";
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
    add_header X-Content-Type-Options nosniff;
    add_header Content-Security-Policy "default-src 'none'; form-action 'none'";
    try_files $uri @proxy;
  }

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

  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;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";

    tcp_nodelay on;
  }

  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;

    tcp_nodelay on;
  }

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

Finally, we have to generate the systemd files so that Mastodon starts up upon reboot

mkdir -p .config/systemd/user
cd .config/systemd/user

podman generate systemd --new --files --name mastodon

loginctl enable-linger
systemctl --user daemon-reload
systemctl --user enable pod-mastodon.service

Congratulations! You have your very own Mastodon instance running. Go ahead and login and get it configured. Have fun!