loop0

Self-hosting mastodon with docker

This is my attempt to document how I deploy my own mastodon instance. My setup is not super straightforward, and this is one of the reasons I decided to write a post about it.

Let me start by listing my requirements:

  1. Run on my NAS
  2. Run on docker
  3. Be publicly accessible (I don’t have static public IP)
  4. Be under my main domain: loop0.sh
  5. Avoid active management/moderation

The infrastructure (#1, #2, and #3)

Here is how I have it deployed:

┌──────────┐
│   User   │
└────┬─────┘
     │ HTTPS
     ▼
┌────────────────────────────────┐
│              VPS               │
│   ┌─────────┐    ┌─────────┐   │
│   │ Traefik │───▶│  Nginx  │   │
│   └─────────┘    └────┬────┘   │
│                       │        │
│                  ┌────▼──────┐ │
│                  │ WireGuard │ │
│                  │  (server) │ │
│                  └─────┬─────┘ │
└────────────────────────┼───────┘
                         │
       ┌─────────────────┘
       ▼
┌──────│──────────────────────────────────┐
│      │           NAS                    │
│  ┌───│────────────────────────────────┐ │
│  │   │        compose.yml             │ │
│  │   └────┐                           │ │
│  │   ┌───────────┐◀───────┌─────────┐ │ │
│  │   │ WireGuard │◀────┐  │ Sidekiq │ │ │
│  │   └────▲──────┘     │  └─────────┘ │ │
│  │        │            │              │ │
│  │   ┌────────┐  ┌───────────┐        │ │
│  │   │  Web   │  │ Streaming │        │ │
│  │   └────────┘  └───────────┘        │ │
│  │   ┌────────┐  ┌─────────┐          │ │
│  │   │Postgres│  │  Redis  │          │ │
│  │   └────────┘  └─────────┘          │ │
│  └────────────────────────────────────┘ │
└─────────────────────────────────────────┘

In this configuration, all inbound requests reach traefik (used for automatic https with letsencrypt), which forwards the traffic to nginx, which is configured as a proxy and forwards the requests to mastodon’s web and streaming containers.

All outbound traffic from the web and sidekiq gets routed through the wireguard container and out through my VPS, so it does not expose my home ip address to the instances it is communicating with.

Note: on the VPS, I have traefik and nginx also running under docker (omitted to simplify)

Even with all those hoops, the responsiveness is pretty good and I have been enjoying having my instance.

Here is my docker compose file that I use to run on the NAS side:

services:
  wireguard:
    image: lscr.io/linuxserver/wireguard:latest
    container_name: wireguard
    restart: always
    cap_add:
      - NET_ADMIN
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Etc/UTC
    dns:
      - 1.1.1.1
    volumes:
      - ./wireguard:/config
    sysctls:
      - net.ipv4.conf.all.src_valid_mark=1
      - net.ipv4.ip_forward=1
    networks:
      - mastodon_external
      - mastodon_internal
  db:
    restart: always
    image: postgres:14-alpine
    shm_size: 256mb
    networks:
      - mastodon_internal
    healthcheck:
      test:
        - CMD
        - pg_isready
        - -U
        - postgres
    volumes:
      - ./postgres:/var/lib/postgresql/data
    env_file: env-postgres
  redis:
    restart: always
    image: redis:7-alpine
    networks:
      - mastodon_internal
    healthcheck:
      test:
        - CMD
        - redis-cli
        - ping
    volumes:
      - ./redis:/data
  web:
    image: ghcr.io/mastodon/mastodon:v4.6
    restart: always
    env_file: env
    command: bundle exec puma -C config/puma.rb
    network_mode: service:wireguard
    healthcheck:
      test:
        - CMD-SHELL
        - curl -s --noproxy localhost localhost:3000/health | grep -q 'OK' ||
          exit 1
    depends_on:
      - db
      - redis
      - wireguard
    volumes:
      - ./mastodon/public/system:/mastodon/public/system:z
  streaming:
    image: ghcr.io/mastodon/mastodon-streaming:v4.6
    restart: always
    env_file: env
    command: node ./streaming/index.js
    network_mode: service:wireguard
    healthcheck:
      test:
        - CMD-SHELL
        - curl -s --noproxy localhost localhost:4000/api/v1/streaming/health |
          grep -q 'OK' || exit 1
    depends_on:
      - db
      - redis
      - wireguard
  sidekiq:
    image: ghcr.io/mastodon/mastodon:v4.6.0
    restart: always
    env_file: env
    command: bundle exec sidekiq
    depends_on:
      - db
      - redis
      - wireguard
    network_mode: service:wireguard
    volumes:
      - ./mastodon/public/system:/mastodon/public/system:z
    healthcheck:
      test:
        - CMD-SHELL
        - ps aux | grep '[s]idekiq 8' || false
networks:
  mastodon_external: null
  mastodon_internal:
    internal: true

The domain (#4)

Problem: I wanted my instance to respond at my main domain https://loop0.sh, so my username would be @root@loop0.sh. The problem is that I have my blog running under that domain, so how can I host mastodon on a subdomain, like https://social.loop0.sh but have it respond on the apex?

Solution: mastodon’s doc says you have to redirect requests with the path /.well-known/webfinger to domain running the instance. My blog is a static one, generated by hugo and running with docker compose as well, served with traefik. With that in mind, I had to add the following labels to my compose.ymlfile:

service:
  web:
    labels:
      # WebFinger redirect router
      - "traefik.http.routers.loop0-webfinger-router.rule=Host(`loop0.sh`) && PathPrefix(`/.well-known/webfinger`)"
      - "traefik.http.routers.loop0-webfinger-router.entrypoints=web,websecure"
      - "traefik.http.routers.loop0-webfinger-router.tls=true"
      - "traefik.http.routers.loop0-webfinger-router.tls.certresolver=myresolver"
      - "traefik.http.routers.loop0-webfinger-router.middlewares=webfinger-redirect"
      - "traefik.http.routers.loop0-webfinger-router.service=noop@internal"
      # WebFinger redirect middleware (preserves query string via {query})
      - "traefik.http.middlewares.webfinger-redirect.redirectregex.regex=^https://loop0\\.sh(/.well-known/webfinger.*)"
      - "traefik.http.middlewares.webfinger-redirect.redirectregex.replacement=https://social.loop0.sh$${1}"
      - "traefik.http.middlewares.webfinger-redirect.redirectregex.permanent=true"

And have mastodon’s env file configured like the following:

LOCAL_DOMAIN=loop0.sh
WEB_DOMAIN=social.loop0.sh

The maintenance (#5)

I don’t have a lot of time to maintain my stuff and I wanted it to be as hands-off as possible, so I decided that the instance had to federate in an allow-list mode, where only domains I choose to federate with would communicate with my instance, and I also wanted to keep it just for my use. Mastodon has a pretty straightforward way to do this, and I just had to add the following lines to my env file:

SINGLE_USER_MODE=true
AUTHORIZED_FETCH=true
LIMITED_FEDERATION_MODE=true

Now with everything running, I had to export my followers and following from my old instance and generate a list of allowed domains.

Closing words

This setup is working pretty well, and I was able to keep everyone from my old account without any issues. It also gives me peace of mind that I don’t have to actively defederate/block domains.

I omitted a bunch of steps because most of it I just took it from mastodon’s own documentation. If you’re a more experienced mastodon admin and see any issues with my deployment, please let me know so I can fix it! You can find me at @root@loop0.sh on mastodon.

Cheers!