Push Notifications
    Docker

    Deploy Gotify on a VPS

    Self-host Gotify, a lightweight push notification server, on a RamNode VPS — Docker, PostgreSQL, TLS, WebSocket-aware reverse proxying, plugins, and backups.

    Gotify is a self-hosted push notification server built around a deliberately small idea: applications get tokens and publish messages, clients get tokens and receive them over a WebSocket. There is no federation, no relay, no third-party push service in the path. Your server holds the connection to your device directly.

    That architecture is Gotify's defining trait, and it cuts both ways. Nothing about your notifications leaves your infrastructure, which is exactly what you want for infrastructure alerts. But it also means the official mobile app is Android only, because delivering to iOS requires an APNs certificate that only Apple issues to a registered developer, and Gotify does not operate a relay to bridge that gap.

    If your devices are Android, a desktop browser, or other servers, Gotify is one of the cleanest self-hosted notification stacks you can run. It is a single Go binary with a built-in web UI and a well-shaped REST API.

    This guide covers a production Gotify deployment on a RamNode VPS: Docker, PostgreSQL instead of the default SQLite, TLS, WebSocket-aware reverse proxying, plugins, and backups.


    Before you start

    Understand the token model first

    Gotify's entire security model is two kinds of token, and confusing them is the most common source of frustration.

    Application tokens publish. You create an application in the UI, it hands you a token, and anything holding that token can send messages as that application. It cannot read anything.

    Client tokens subscribe. Your Android app or browser session holds one. It can read all messages across all applications and manage the server via the API.

    The asymmetry is the point. Your web server holds an application token. If that server is compromised, the attacker can send you fake notifications, which is annoying. They cannot read your other notifications and cannot touch the server. Never put a client token in a script.

    Pick the right RamNode plan

    Gotify is small.

    DeploymentRecommended specs
    Personal, a few applications, SQLite1 GB RAM, 1 vCPU, 20 GB SSD
    Small team, PostgreSQL, image attachments2 GB RAM, 1 vCPU, 40 GB SSD
    Heavy use, many concurrent clients, long retention4 GB RAM, 2 vCPU, 60 GB NVMe

    The smallest RamNode plan is genuinely sufficient for personal use. Idle memory is in the tens of megabytes.

    The scaling factor is concurrent WebSocket connections, which cost file descriptors and a little memory, not CPU.

    SQLite or PostgreSQL

    Gotify defaults to SQLite and that is a legitimate production choice for a single-instance deployment. It is fast, it has no separate service to babysit, and backups are a file copy.

    Use PostgreSQL if you keep long message history, run many concurrent clients, or want the operational tooling you already have for Postgres. This guide uses PostgreSQL because migrating later is not supported and picking it up front costs you nothing but a container.

    If you want SQLite instead, skip the postgres service and set the connection string as noted in Step 4.

    DNS

    shell
    gotify.example.com.  A     198.51.100.42
    gotify.example.com.  AAAA  2001:db8::1
    shell
    dig +short gotify.example.com A

    Set reverse DNS in the RamNode control panel to match.

    Email

    Gotify does not send email. There is nothing to configure and RamNode's mail restrictions are irrelevant here. If you want email delivery of notifications, that is a job for a plugin or a downstream forwarder.


    Step 1: Base system preparation

    Clean Ubuntu 24.04 LTS from the RamNode control panel.

    shell
    apt update && apt upgrade -y
    apt install -y curl ca-certificates gnupg ufw fail2ban unattended-upgrades jq

    Hostname

    shell
    hostnamectl set-hostname gotify.example.com
    echo "127.0.1.1 gotify.example.com gotify" >> /etc/hosts

    Admin user and SSH

    shell
    adduser deploy
    usermod -aG sudo deploy
    rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

    /etc/ssh/sshd_config:

    shell
    PermitRootLogin no
    PasswordAuthentication no
    PubkeyAuthentication yes
    shell
    systemctl reload ssh

    Firewall

    shell
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw enable

    File descriptor limits

    Every connected client holds an open WebSocket.

    /etc/security/limits.conf:

    shell
    *  soft  nofile  65535
    *  hard  nofile  65535

    /etc/sysctl.d/99-gotify.conf:

    shell
    fs.file-max = 200000
    net.core.somaxconn = 4096
    shell
    sysctl --system

    Unattended upgrades

    shell
    dpkg-reconfigure --priority=low unattended-upgrades

    Step 2: Install Docker

    shell
    install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
    chmod a+r /etc/apt/keyrings/docker.asc
    
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
    https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
      > /etc/apt/sources.list.d/docker.list
    
    apt update
    apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    usermod -aG docker deploy

    Cap log growth in /etc/docker/daemon.json:

    shell
    {
      "log-driver": "json-file",
      "log-opts": {
        "max-size": "50m",
        "max-file": "3"
      }
    }
    shell
    systemctl restart docker

    Step 3: Lay out the stack

    As deploy:

    shell
    mkdir -p ~/gotify/{data,postgres,plugins}
    cd ~/gotify

    Generate secrets:

    shell
    openssl rand -hex 24   # postgres password
    openssl rand -base64 20 # admin password

    Step 4: The Compose stack

    Create ~/gotify/docker-compose.yml:

    shell
    name: gotify
    
    x-logging: &default-logging
      driver: json-file
      options:
        max-size: "50m"
        max-file: "3"
    
    services:
      gotify:
        image: gotify/server:2.6.3
        restart: always
        ports:
          # Localhost only. Caddy on the host terminates TLS.
          - "127.0.0.1:8070:80"
        environment:
          # ---- Server ----
          # Trust X-Forwarded-For from the local reverse proxy so
          # logs and rate limiting see real client IPs.
          - GOTIFY_SERVER_TRUSTEDPROXIES=127.0.0.1/32
    
          # CORS. Only needed if you serve a separate frontend from
          # another origin. Most people do not.
          # - GOTIFY_SERVER_CORS_ALLOWORIGINS=- "^.*example.com.*$"
    
          # ---- Bootstrap admin ----
          # Applied only when the database is empty. Change the
          # password in the UI afterward and it is ignored on
          # subsequent starts.
          - GOTIFY_DEFAULTUSER_NAME=admin
          - GOTIFY_DEFAULTUSER_PASS=YOUR_ADMIN_PASSWORD
    
          # ---- Password hashing ----
          # Higher is slower to brute force and slower to log in.
          # 10 is the default, 12 is a reasonable production value.
          - GOTIFY_PASSSTRENGTH=12
    
          # ---- Uploads ----
          - GOTIFY_UPLOADEDIMAGESDIR=/app/data/images
    
          # ---- Plugins ----
          - GOTIFY_PLUGINSDIR=/app/data/plugins
    
          # ---- Database ----
          # PostgreSQL. For SQLite instead, use:
          #   GOTIFY_DATABASE_DIALECT=sqlite3
          #   GOTIFY_DATABASE_CONNECTION=data/gotify.db
          # and drop the postgres service below.
          - GOTIFY_DATABASE_DIALECT=postgres
          - GOTIFY_DATABASE_CONNECTION=host=postgres port=5432 user=gotify dbname=gotify password=YOUR_POSTGRES_PASSWORD sslmode=disable
    
          # ---- Registration ----
          # Leave false. Gotify has no email verification, no
          # captcha, and no approval queue. Open registration on a
          # public URL means an open notification server.
          - GOTIFY_REGISTRATION=false
    
          # ---- Streaming ----
          # WebSocket ping interval in seconds. Keeps connections
          # alive through proxies and NAT that drop idle sockets.
          - GOTIFY_SERVER_STREAM_PINGPERIODSECONDS=45
          # Origins permitted to open a WebSocket. Set this to your
          # real hostname.
          - GOTIFY_SERVER_STREAM_ALLOWEDORIGINS=- "gotify.example.com"
    
        volumes:
          - ./data:/app/data
        depends_on:
          postgres:
            condition: service_healthy
        logging: *default-logging
    
      postgres:
        image: postgres:16-alpine
        restart: always
        environment:
          - POSTGRES_USER=gotify
          - POSTGRES_PASSWORD=YOUR_POSTGRES_PASSWORD
          - POSTGRES_DB=gotify
        volumes:
          - ./postgres:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U gotify -d gotify"]
          interval: 10s
          timeout: 5s
          retries: 5
        logging: *default-logging

    Note the $ escaping in the commented CORS line. Compose interpolates single $, so any regex containing $ needs doubling.

    Bring it up:

    shell
    docker compose up -d
    docker compose logs -f gotify

    Verify locally:

    shell
    curl -s http://127.0.0.1:8070/version | jq

    You should get version, commit, and build date back.

    On config.yml versus environment variables

    Gotify accepts either. Environment variables map to config keys with GOTIFY_ prefix and underscore separators, so server.stream.pingperiodseconds becomes GOTIFY_SERVER_STREAM_PINGPERIODSECONDS.

    Environment variables in the compose file keep everything in one place and version-controllable. If you prefer a config file, mount one at /etc/gotify/config.yml and drop the environment block. Do not use both, because the precedence rules will confuse you at 2am.


    Step 5: Reverse proxy and TLS

    Gotify's clients hold persistent WebSocket connections. Your proxy must upgrade them and must not time them out.

    Install Caddy:

    shell
    sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
      | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
      | sudo tee /etc/apt/sources.list.d/caddy-stable.list
    sudo apt update
    sudo apt install -y caddy

    /etc/caddy/Caddyfile:

    shell
    gotify.example.com {
        encode gzip
    
        # Image attachments.
        request_body {
            max_size 20MB
        }
    
        reverse_proxy 127.0.0.1:8070 {
            # Caddy handles WebSocket upgrade automatically.
            # This just keeps streamed frames from being buffered.
            flush_interval -1
        }
    
        header {
            Strict-Transport-Security "max-age=31536000; includeSubDomains"
            X-Content-Type-Options "nosniff"
            Referrer-Policy "strict-origin-when-cross-origin"
            -Server
        }
    
        log {
            output file /var/log/caddy/gotify.log {
                roll_size 50mb
                roll_keep 5
            }
        }
    }
    shell
    sudo caddy validate --config /etc/caddy/Caddyfile
    sudo systemctl reload caddy

    If you prefer nginx

    The WebSocket bits are not optional:

    shell
    server {
        listen 443 ssl http2;
        listen [::]:443 ssl http2;
        server_name gotify.example.com;
    
        ssl_certificate     /etc/letsencrypt/live/gotify.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/gotify.example.com/privkey.pem;
    
        client_max_body_size 20M;
    
        location / {
            proxy_pass http://127.0.0.1:8070;
    
            # Required for WebSocket. HTTP/1.0 cannot upgrade.
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
    
            proxy_set_header Host $http_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_buffering off;
            proxy_redirect off;
    
            # The stream is idle between notifications. The 60s
            # default closes it and your clients reconnect forever.
            proxy_read_timeout 3600s;
            proxy_send_timeout 3600s;
        }
    }

    Verify:

    shell
    curl -s https://gotify.example.com/version | jq

    Open https://gotify.example.com and log in as admin with the password you set.


    Step 6: First-run configuration

    Change the admin password

    The bootstrap password is in your compose file in plaintext. Log in, go to Users, and change it. Once changed, GOTIFY_DEFAULTUSER_PASS is inert, but leave nothing in a file you do not have to.

    Create applications

    Applications are your publishers. One per source, not one for everything.

    Go to Apps, click Create Application, give it a name and description. Upload an icon if you want the notification to be visually distinguishable at a glance, which is worth more than it sounds when you have five sources.

    Copy the token. It starts with A.

    Reasonable application layout:

    shell
    web01-alerts      -> nginx, disk, load from your web server
    backups           -> backup scripts
    ci                -> pipeline results
    uptime            -> monitoring checks

    Create clients

    Clients are your consumers. Go to Clients, create one per device.

    shell
    android-phone
    desktop-browser

    Client tokens start with C. These read everything. Guard them accordingly.

    Create non-admin users

    If more than one person uses this, create regular users rather than sharing the admin account. Each user sees only messages from applications they own, which means a shared Gotify is really several independent notification streams under one roof.


    Step 7: Verify and use

    Send a message

    shell
    curl -X POST "https://gotify.example.com/message?token=YOUR_APP_TOKEN" \
         -F "title=Test" \
         -F "message=Hello from RamNode" \
         -F "priority=5"

    JSON form

    shell
    curl -X POST "https://gotify.example.com/message" \
         -H "X-Gotify-Key: YOUR_APP_TOKEN" \
         -H "Content-Type: application/json" \
         -d '{
           "title": "Deploy complete",
           "message": "v2.4.1 is live on web01",
           "priority": 5
         }'

    The X-Gotify-Key header is preferable to the query parameter, since query strings end up in proxy access logs.

    Priority behavior

    Gotify priorities are 0 to 10 and the Android client maps them to notification importance:

    PriorityBehavior
    0 to 3Silent, no popup
    4 to 7Normal notification with sound
    8 to 10High priority, bypasses Do Not Disturb

    Use 8+ only for things you would want to be woken up for. Everything is urgent means nothing is.

    Markdown

    shell
    curl -X POST "https://gotify.example.com/message" \
         -H "X-Gotify-Key: YOUR_APP_TOKEN" \
         -H "Content-Type: application/json" \
         -d '{
           "title": "Disk report",
           "message": "**web01** is at `94%`\n\n- /var 88%\n- /home 96%",
           "priority": 8,
           "extras": {
             "client::display": { "contentType": "text/markdown" }
           }
         }'

    The extras block is where Gotify's real flexibility lives. client::display controls rendering. client::notification can attach a click URL:

    shell
    "extras": {
      "client::notification": {
        "click": { "url": "https://grafana.example.com/d/abc123" }
      }
    }

    Test the WebSocket

    This is the check that catches proxy misconfiguration:

    shell
    curl -s -N \
      -H "Connection: Upgrade" \
      -H "Upgrade: websocket" \
      -H "Sec-WebSocket-Version: 13" \
      -H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
      "https://gotify.example.com/stream?token=YOUR_CLIENT_TOKEN"

    Expect HTTP/1.1 101 Switching Protocols. If you get 200 or 400, your proxy is not upgrading and no client will ever receive a push.

    A reusable notify wrapper

    /usr/local/bin/gotify-run:

    shell
    #!/usr/bin/env bash
    # Usage: gotify-run <command...>
    
    GOTIFY_URL="https://gotify.example.com"
    GOTIFY_TOKEN="YOUR_APP_TOKEN"
    HOST=$(hostname -s)
    
    START=$(date +%s)
    OUTPUT=$("$@" 2>&1)
    RC=$?
    DURATION=$(( $(date +%s) - START ))
    
    if [ $RC -eq 0 ]; then
        TITLE="OK: $1 on $HOST"
        PRIORITY=3
    else
        TITLE="FAILED (rc=$RC): $1 on $HOST"
        PRIORITY=8
    fi
    
    BODY=$(printf '```\n%s\n```\n\nDuration: %ss' "${OUTPUT:0:2000}" "$DURATION")
    
    jq -n --arg t "$TITLE" --arg m "$BODY" --argjson p "$PRIORITY" \
      '{title:$t, message:$m, priority:$p,
        extras:{"client::display":{contentType:"text/markdown"}}}' \
      | curl -s -X POST "$GOTIFY_URL/message" \
             -H "X-Gotify-Key: $GOTIFY_TOKEN" \
             -H "Content-Type: application/json" \
             -d @- > /dev/null
    
    exit $RC
    shell
    chmod +x /usr/local/bin/gotify-run

    In cron:

    shell
    30 2 * * * /usr/local/bin/gotify-run /home/deploy/backup.sh

    Failure output arrives on your phone in markdown, formatted, with the exit code preserved for cron.


    Step 8: Clients

    Android. The official app is on F-Droid and Google Play. Add your server URL, log in with your username and password, and it registers a client for itself. It holds a persistent WebSocket, which means battery impact is real but modest, and it works without Google Play Services.

    Browser. The built-in web UI at your server URL shows a live message stream. It is a real client, not just an admin panel.

    Desktop. Several third-party tray clients exist. They authenticate with a client token against /stream.

    iOS. There is no official app. Some third-party apps can poll the Gotify API, but polling is not push and the battery and latency tradeoffs are poor. If iOS is your primary target, Gotify is the wrong tool and you should look at ntfy instead, which operates a relay specifically to solve this.

    UnifiedPush. Gotify can act as a UnifiedPush distributor via a plugin, which lets other Android apps route their push through your server instead of Google's. Worth knowing about if you are already de-Googling.


    Step 9: Plugins

    Gotify plugins are compiled Go shared objects. They must be built against the exact Gotify version and Go version your server runs, which makes this less casual than it sounds. A plugin built for 2.6.1 will not load on 2.6.3.

    To install a prebuilt plugin, drop the .so into ./data/plugins and restart:

    shell
    cd ~/gotify
    cp gotify-plugin-example-linux-amd64.so data/plugins/
    docker compose restart gotify
    docker compose logs gotify | grep -i plugin

    Enable it in the UI under Plugins.

    Common uses are protocol bridges, message transformation, and scheduled polling that publishes results as messages.

    If a plugin fails to load, the version mismatch is nearly always why. Check the startup log, which names the expected ABI.


    Step 10: Backups

    The database holds your users, applications, tokens, and messages. Losing it means reissuing every token on every publishing system you own, which is the actual pain, not the lost messages.

    /home/deploy/gotify-backup.sh:

    shell
    #!/usr/bin/env bash
    set -euo pipefail
    
    BACKUP_DIR="/home/deploy/backups"
    STACK_DIR="/home/deploy/gotify"
    STAMP=$(date +%Y%m%d-%H%M%S)
    RETAIN_DAYS=30
    
    mkdir -p "$BACKUP_DIR"
    
    # Database.
    docker compose -f "$STACK_DIR/docker-compose.yml" exec -T postgres \
      pg_dump -U gotify -Fc gotify > "$BACKUP_DIR/gotify-db-$STAMP.dump"
    
    # Data dir: uploaded images, plugin binaries, plugin config.
    tar czf "$BACKUP_DIR/gotify-data-$STAMP.tar.gz" -C "$STACK_DIR" data
    
    # Compose file. Holds your credentials.
    cp "$STACK_DIR/docker-compose.yml" "$BACKUP_DIR/gotify-compose-$STAMP.yml"
    
    find "$BACKUP_DIR" -name 'gotify-*' -mtime +$RETAIN_DAYS -delete

    For SQLite instead of the pg_dump line:

    shell
    sqlite3 "$STACK_DIR/data/gotify.db" ".backup '$BACKUP_DIR/gotify-$STAMP.db'"
    shell
    chmod +x /home/deploy/gotify-backup.sh
    crontab -e
    shell
    0 4 * * * /home/deploy/gotify-backup.sh >> /home/deploy/gotify-backup.log 2>&1

    Ship the results off the box. RamNode snapshots are a useful whole-machine layer but not a substitute.

    Restore

    shell
    cd ~/gotify
    docker compose stop gotify
    docker compose exec -T postgres dropdb -U gotify gotify
    docker compose exec -T postgres createdb -U gotify gotify
    docker compose exec -T postgres pg_restore -U gotify -d gotify --clean --if-exists \
      < ~/backups/gotify-db-20260716-040000.dump
    docker compose up -d

    Existing tokens keep working after a restore, which is the whole point of backing up the database rather than shrugging at lost messages.


    Step 11: Maintenance

    Upgrading

    shell
    cd ~/gotify
    ./gotify-backup.sh
    # bump the image tag in docker-compose.yml
    docker compose pull
    docker compose up -d
    docker compose logs -f gotify

    Gotify migrates its schema automatically on start. If you run plugins, they will fail to load after a version bump until you replace them with builds matching the new version. Plan for that or run without plugins.

    Pin the tag. latest on a service your alerting depends on is how you find out about a breaking change at the worst possible time.

    Prune old messages

    Gotify does not expire messages on its own. They accumulate until you do something about it.

    Per application, via the API:

    shell
    # List applications and their IDs.
    curl -s -H "X-Gotify-Key: YOUR_CLIENT_TOKEN" \
      https://gotify.example.com/application | jq '.[] | {id, name}'
    
    # Delete all messages for application 3.
    curl -X DELETE -H "X-Gotify-Key: YOUR_CLIENT_TOKEN" \
      https://gotify.example.com/application/3/message

    Delete everything:

    shell
    curl -X DELETE -H "X-Gotify-Key: YOUR_CLIENT_TOKEN" \
      https://gotify.example.com/message

    Age-based pruning is not exposed in the API, so if you want it, do it at the database:

    shell
    docker compose exec postgres psql -U gotify -d gotify -c \
      "DELETE FROM messages WHERE date < now() - interval '90 days';"

    Put that in cron and stop thinking about it.

    Watch database size

    shell
    docker compose exec postgres psql -U gotify -d gotify -c \
      "SELECT pg_size_pretty(pg_database_size('gotify'));"

    Watch connections

    shell
    ss -tn state established '( sport = :8070 )' | wc -l

    A number that climbs steadily without new clients means something is reconnecting in a loop. Check the proxy timeout first.

    Logs

    shell
    docker compose logs -f gotify

    Troubleshooting

    Web UI works, no push arrives. The WebSocket is not upgrading. Run the 101 check from Step 7. In nginx, this is proxy_http_version 1.1 plus the Upgrade and Connection headers.

    Clients connect then drop every 60 seconds. nginx proxy_read_timeout. The stream is idle by design between notifications. Raise it to 3600s.

    Android app reconnects constantly on mobile data. Carrier NAT dropping idle connections. Lower GOTIFY_SERVER_STREAM_PINGPERIODSECONDS to 30 so the keepalive fires before the NAT entry expires.

    401 on publish. You are using a client token where an application token belongs, or the token is wrong. Application tokens start with A, client tokens with C.

    403 on WebSocket. GOTIFY_SERVER_STREAM_ALLOWEDORIGINS does not include your hostname. Set it to the actual public hostname.

    Container will not start after an upgrade. docker compose logs gotify. Usually a plugin built for the previous version. Move it out of data/plugins and restart.

    Cannot connect to the database. Confirm the healthcheck passes with docker compose ps. The depends_on: condition: service_healthy should prevent this, but a wrong password in the connection string produces the same symptom with a very different fix.

    Uploaded icons do not display. Check data/images exists and is writable by the container user, and that client_max_body_size or Caddy's max_size is not rejecting the upload.

    Real client IPs missing from logs. GOTIFY_SERVER_TRUSTEDPROXIES must include your proxy's address.


    Wrapping up

    You have Gotify on a RamNode VPS with TLS, PostgreSQL, WebSocket-aware proxying, an application-per-source token layout, and tested backups.

    The thing that makes Gotify pleasant in practice is the token asymmetry. Once you have applications scoped per source, handing an application token to a script is a low-consequence act. That token can annoy you and nothing more. Compare that to systems where the publishing credential is also the reading credential and you will appreciate the design.

    Two decisions to make honestly up front: if any of your targets are iPhones, use something else, because Gotify's direct-connection architecture has no answer for APNs. And if you plan to use plugins, accept that every upgrade is a plugin rebuild, and decide whether that maintenance is worth what the plugin buys you.