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!