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.
| Deployment | Recommended specs |
|---|---|
| Personal, a few applications, SQLite | 1 GB RAM, 1 vCPU, 20 GB SSD |
| Small team, PostgreSQL, image attachments | 2 GB RAM, 1 vCPU, 40 GB SSD |
| Heavy use, many concurrent clients, long retention | 4 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
gotify.example.com. A 198.51.100.42
gotify.example.com. AAAA 2001:db8::1dig +short gotify.example.com ASet reverse DNS in the RamNode control panel to match.
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.
apt update && apt upgrade -y
apt install -y curl ca-certificates gnupg ufw fail2ban unattended-upgrades jqHostname
hostnamectl set-hostname gotify.example.com
echo "127.0.1.1 gotify.example.com gotify" >> /etc/hostsAdmin user and SSH
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yessystemctl reload sshFirewall
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enableFile descriptor limits
Every connected client holds an open WebSocket.
/etc/security/limits.conf:
* soft nofile 65535
* hard nofile 65535/etc/sysctl.d/99-gotify.conf:
fs.file-max = 200000
net.core.somaxconn = 4096sysctl --systemUnattended upgrades
dpkg-reconfigure --priority=low unattended-upgradesStep 2: Install Docker
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 deployCap log growth in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}systemctl restart dockerStep 3: Lay out the stack
As deploy:
mkdir -p ~/gotify/{data,postgres,plugins}
cd ~/gotifyGenerate secrets:
openssl rand -hex 24 # postgres password
openssl rand -base64 20 # admin passwordStep 4: The Compose stack
Create ~/gotify/docker-compose.yml:
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-loggingNote the $ escaping in the commented CORS line. Compose interpolates single $, so any regex containing $ needs doubling.
Bring it up:
docker compose up -d
docker compose logs -f gotifyVerify locally:
curl -s http://127.0.0.1:8070/version | jqYou 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:
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:
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
}
}
}sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddyIf you prefer nginx
The WebSocket bits are not optional:
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:
curl -s https://gotify.example.com/version | jqOpen 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:
web01-alerts -> nginx, disk, load from your web server
backups -> backup scripts
ci -> pipeline results
uptime -> monitoring checksCreate clients
Clients are your consumers. Go to Clients, create one per device.
android-phone
desktop-browserClient 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
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
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:
| Priority | Behavior |
|---|---|
| 0 to 3 | Silent, no popup |
| 4 to 7 | Normal notification with sound |
| 8 to 10 | High 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
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:
"extras": {
"client::notification": {
"click": { "url": "https://grafana.example.com/d/abc123" }
}
}Test the WebSocket
This is the check that catches proxy misconfiguration:
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:
#!/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 $RCchmod +x /usr/local/bin/gotify-runIn cron:
30 2 * * * /usr/local/bin/gotify-run /home/deploy/backup.shFailure 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:
cd ~/gotify
cp gotify-plugin-example-linux-amd64.so data/plugins/
docker compose restart gotify
docker compose logs gotify | grep -i pluginEnable 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:
#!/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 -deleteFor SQLite instead of the pg_dump line:
sqlite3 "$STACK_DIR/data/gotify.db" ".backup '$BACKUP_DIR/gotify-$STAMP.db'"chmod +x /home/deploy/gotify-backup.sh
crontab -e0 4 * * * /home/deploy/gotify-backup.sh >> /home/deploy/gotify-backup.log 2>&1Ship the results off the box. RamNode snapshots are a useful whole-machine layer but not a substitute.
Restore
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 -dExisting 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
cd ~/gotify
./gotify-backup.sh
# bump the image tag in docker-compose.yml
docker compose pull
docker compose up -d
docker compose logs -f gotifyGotify 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:
# 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/messageDelete everything:
curl -X DELETE -H "X-Gotify-Key: YOUR_CLIENT_TOKEN" \
https://gotify.example.com/messageAge-based pruning is not exposed in the API, so if you want it, do it at the database:
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
docker compose exec postgres psql -U gotify -d gotify -c \
"SELECT pg_size_pretty(pg_database_size('gotify'));"Watch connections
ss -tn state established '( sport = :8070 )' | wc -lA number that climbs steadily without new clients means something is reconnecting in a loop. Check the proxy timeout first.
Logs
docker compose logs -f gotifyTroubleshooting
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.
