Installing GTS#

I got an Oracle Cloud instance, so I decided to set up my own Fediverse instance. After some thought, I went with GoToSocial — it’s fairly lightweight and works with any Mastodon API-compatible client. The downside is that it’s pretty bare-minimum: features like emoji reactions and quote posts are absent.

The official GTS docker-compose.yaml can be fetched like this:

wget https://codeberg.org/superseriousbusiness/gotosocial/raw/branch/main/example/docker-compose/docker-compose.yaml
services:
  gotosocial:
    image: docker.io/superseriousbusiness/gotosocial:latest
    container_name: gotosocial
    user: 1000:1000
    networks:
      - gotosocial
    environment:
      # Change this to your actual host value.
      GTS_HOST: example.org
      GTS_DB_TYPE: sqlite
      # Path in the GtS Docker container where
      # the sqlite.db file will be stored.
      GTS_DB_ADDRESS: /gotosocial/storage/sqlite.db
      # Change this to true if you're not running
      # GoToSocial behind a reverse proxy.
      GTS_LETSENCRYPT_ENABLED: "false"
      # Set your email address here if you
      # want to receive letsencrypt notices.
      GTS_LETSENCRYPT_EMAIL_ADDRESS: ""
      # Path in the GtS Docker container where the
      # Wazero compilation cache will be stored.
      GTS_WAZERO_COMPILATION_CACHE: /gotosocial/.cache
      ## For reverse proxy setups:
      GTS_TRUSTED_PROXIES: "172.18.0.1/16"
      ## Set the timezone of your server:
      #TZ: UTC
    ports:
      - "443:8080"
      ## For letsencrypt:
      #- "80:80"
      ## For reverse proxy setups:
      #- "127.0.0.1:8080:8080"
    volumes:
      # Your data volume, for your
      # sqlite.db file and media files.
      - ~/gotosocial/data:/gotosocial/storage
      # OPTIONAL: To mount volume for the WAZERO
      # compilation cache, for speedier restart
      # times, uncomment the below line:
      #- ~/gotosocial/.cache:/gotosocial/.cache
    restart: "always"

networks:
  gotosocial:
    ipam:
      driver: default
      config:
        - subnet: "172.18.0.0/16"
          gateway: "172.18.0.1"

Since I already had Caddy running on the machine, I commented out all the reverse-proxy-related parts.

I also added a few lines under environment (GTS has a separate config file for environment variables, but if you don’t want to mount it you can write them directly in compose.yml — just uppercase the variable name, replace - with _, and prepend GTS_. See the docs):

      GTS_ACCOUNTS_ALLOW_CUSTOM_CSS: true # allow custom CSS per user
      TZ: Europe/Berlin
      GTS_INSTANCE_LANGUAGES: "zh,en,nl,de,fr,ja"

And under volumes I mounted a custom font:

      - /home/ubuntu/gotosocial/fonts/GeistPixel-Square.woff2:/gotosocial/web/assets/fonts/GeistPixel-Square.woff2:ro

After doing this, the font can be referenced in CSS.

CSS Customisation#

GTS profile themes come from two sources: themes uploaded by the admin as preset options, and CSS written directly by users. Both layers stack on top of each other.

The site-wide CSS is also customisable. I went with a combination of Catppuccin Frappé, Geist Pixel Square, and Fusion Pixel.

Backing Up the Database and Media#

Honestly, I’ve always been hesitant about self-hosting, because I don’t have much confidence in my own backups. If the database gets corrupted, the signing keys are lost, and rejoining the federation from the same domain becomes extremely painful.

The GTS docs have a detailed guide on backup and restore, and the recommended database backup tool is Borgmatic. Though I was lazy and hadn’t read that section before writing this, so I didn’t use it.

For backing up media and the database I use an S3 bucket, mounted via rclone.

You could just upload everything from the Docker-mapped folder on the host, but that’s inelegant — the media directory includes caches from remote instances, which take up a lot of space but don’t need to be backed up. You only need to preserve media from local accounts.

The GTS CLI tools provide gotosocial admin media list-attachments and gotosocial admin media list-emojis. Adding the --local-only flag lists only local media files.

The output looks something like this:

/gotosocial/062G5WYKY35KKD12EMSM3F8PJ8/attachment/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg
/gotosocial/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg
/gotosocial/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg
/gotosocial/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg
/gotosocial/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH7TDVANYKWVE8VVKFPJTJ.gif
/gotosocial/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg
/gotosocial/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH58A357CV5K7R7TJMSH6S.jpg
/gotosocial/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01CDR64G398ADCHXK08WWTHEZ5.gif

Using this, we can filter media by local account ID during scheduled backups.

Here’s an example backup script using rclone against the host folder mapped from the Docker container:

/usr/local/bin/rclone-backup-optimized.sh

#!/usr/bin/env bash
set -uo pipefail


SRC="/home/ubuntu/gotosocial/data"
MEDIA_DST="remote:bucket/media"
DB_DST="remote:bucket/db"
CONF="/home/ubuntu/.config/rclone/rclone.conf"
COMPOSE_FILE="/home/ubuntu/gotosocial/docker-compose.yaml"
SERVICE="gotosocial"     # container name
GTS_BIN="/gotosocial/gotosocial"            
DB_FILE="/home/ubuntu/gotosocial/data/sqlite.db"   


#  SQLite (vacuum into snapshot)
DB_OK=1; DB_OUT=""
SNAPDIR=$(mktemp -d); SNAP="$SNAPDIR/sqlite.db"
if DB_OUT=$(sqlite3 "$DB_FILE" "PRAGMA busy_timeout=10000; VACUUM INTO '$SNAP'" 2>&1); then
  CHK=$(sqlite3 "$SNAP" "PRAGMA integrity_check" 2>&1)
  if [ "$CHK" = "ok" ]; then
    if DB_OUT=$(rclone copy "$SNAP" "$DB_DST/" --config "$CONF" 2>&1); then
      DB_OK=0
    fi
  else
    DB_OUT="Snapshot integrity failed: $CHK"
  fi
fi
rm -rf "$SNAPDIR"

# list local account ids
get_ids() {
  sudo docker  exec  "$SERVICE" \
    "$GTS_BIN" admin media "$1" --local-only 2>/dev/null \
    | grep -oE '/[0-9A-HJKMNP-TV-Z]{26}/(attachment|emoji)/' \
    | grep -oE '[0-9A-HJKMNP-TV-Z]{26}'
}
IDS=$( { get_ids list-attachments; get_ids list-emojis; } | sort -u )

# sync media
FILTER=$(mktemp)
while IFS= read -r id; do printf '+ /%s/**\n' "$id"; done <<< "$IDS" >> "$FILTER"
printf -- '- **\n' >> "$FILTER"
MEDIA_OUT=$(rclone sync "$SRC" "$MEDIA_DST" --filter-from "$FILTER" --config "$CONF" 2>&1)
MEDIA_CODE=$?
rm -f "$FILTER"

After each backup you can also send a Discord webhook notification so you know if something goes wrong.

I added this to the script:

WEBHOOK="https://discord.com/api/webhooks/.../..." 

notify() {  # $1=title  $2=color  $3=description
  local payload
  payload=$(jq -n --arg t "$1" --arg d "$3" --arg h "$(hostname)" --argjson c "$2" \
    '{embeds:[{title:$t, description:$d, color:$c, footer:{text:$h}, timestamp:(now|todate)}]}')
  curl -sf -H "Content-Type: application/json" -d "$payload" "$WEBHOOK" >/dev/null
}

NUM=$(printf '%s\n' "$IDS" | wc -l | tr -d ' ')
[ "$DB_OK" -eq 0 ] && DB_LINE="Database: success" || DB_LINE="Database: failed ($(printf '%s' "$DB_OUT" | tail -c 300))"
[ "$MEDIA_CODE" -eq 0 ] && MEDIA_LINE="Media: success (${NUM} local account directory/ies)" || MEDIA_LINE="Media: failed (exit $MEDIA_CODE)"

if [ "$DB_OK" -eq 0 ] && [ "$MEDIA_CODE" -eq 0 ]; then
  TITLE="GtS backup success"; COLOR=3066993
else
  TITLE="GtS backup failed"; COLOR=15158332
fi
TAIL=$(printf '%s' "$MEDIA_OUT" | tail -c 1000)
DESC=$(printf '%s\n%s\n```\n%s\n```' "$DB_LINE" "$MEDIA_LINE" "${TAIL:-(Media no output)}")
notify "$TITLE" "$COLOR" "$DESC"

Phanpy: A Nicer Frontend#

Installing Phanpy is quite painless since it’s a purely static site — you can just download a release and serve it behind a web server. However, the project doesn’t recommend this approach; the preferred way is a custom build.

After cloning the repo, something like this works:

PHANPY_DEFAULT_INSTANCE=social.obsp.de \
    PHANPY_CLIENT_NAME="Observer's Space Social" \
	    PHANPY_WEBSITE="https://phanpy.obsp.de" 
    PHANPY_PRIVACY_POLICY_URL=https://social.obsp.de/about \
    npm run build

The dist folder will contain everything you need.

Phanpy doesn’t yet support an instance whitelist, so I made a small modification to src/pages/login.jsx to restrict login to a single specific instance.