Goodbye Discord webhooks, hello Gotify

· 0ut3r Space ·

21 min read Original article ↗

For a long time I used Discord webhooks for notifications from my services. It was easy, it worked and almost every application knew how to send something to Discord. Create a private channel, copy a webhook URL, paste it into a service and wait until something breaks. Very advanced engineering.

The more services I added, however, the stranger it started to feel. My own servers were sending operational information to a third-party chat platform only so the same platform could send it back to my phone.

Discord was not doing anything wrong here. I was simply using a communication platform as my private monitoring inbox because it was convenient.

If you are a regular reader, you probably already know what happened next. I found a self-hosted alternative and created more work for myself in the name of independence.

This time the solution is Gotify.

gotify

Gotify is a small self-hosted server for sending and receiving real-time notifications. It has a WebUI, an Android application, a REST API and native support in many self-hosted tools. When native support is missing, sending a message still requires only one HTTP request.

I have connected it to my Uptime Kuma instance, so availability and recovery alerts now arrive through my own notification server. I also added Gotify notifications to HackHub.fyi for administrative events such as registrations, submissions, update suggestions, reports and contact messages.

No Discord server, no private notification channel and no webhook controlled by another company. Just my service talking to my other service.

As nature intended.

What Gotify actually does

The model is simple.

A Gotify user owns clients and applications. A client receives and manages messages. An application sends messages and receives its own application token.

A server, script or website sends an HTTPS request using that token. Gotify stores the message and delivers it to connected clients through WebSockets.

1
2
3
4
5
6
7
8
9
Website, server or script
|
| HTTPS request with an application token
v
Gotify server
|
+---- WebUI
|
+---- Android application

There are no channels to create and no bot account to invite.

I can create an application called Uptime Kuma and use its token only in Uptime Kuma. Another application can be called Hack Hub and receive a different token. A third one can be used by a backup script or a VPS health checker.

This separation matters. If one token leaks, I rotate only that application token instead of replacing one universal secret in every service I own.

Gotify 3 also changed token handling. Application and client tokens are exposed only when they are created or rotated. Save a new token immediately. Closing the window and expecting it to remain visible later will provide a practical lesson about token rotation.

My setup

I installed Gotify 3.0.0 on a small Debian VPS which already runs Nginx and Uptime Kuma.

Gotify listens only on localhost. Nginx handles the public HTTPS connection and proxies traffic to the local Gotify service.

1
2
3
4
5
6
7
Internet
|
v
Nginx on ports 80 and 443
|
v
Gotify on 127.0.0.1:2586

The examples use:

1
notify.example.com

Replace that domain everywhere with your own.

I use SQLite because this is a private notification server for a small number of applications. The database and uploaded application images live under /var/lib/gotify.

Gotify supports Docker and the official container images are probably the easiest route when the rest of a server already uses Docker Compose. My monitoring VPS did not use Docker. Installing Docker, containerd, extra networking and the rest of the machinery only to run one small Go binary felt like using a forklift to move a coffee cup.

I therefore installed the official standalone binary and created a hardened systemd service.

This article uses Gotify 3.0.0, released on July 18, 2026. Check the Gotify releases before installing because the current version may have changed by the time you read this.

Gotify 3 uses a new environment-based configuration format and the server binary now supports commands such as serve, version and migrate-config. A configuration written for Gotify 2 should not be copied blindly into Gotify 3.

Create a dedicated user and directories

Gotify does not need to run as root.

Create a system account without an interactive shell:

1
2
3
4
5
getent passwd gotify >/dev/null || sudo useradd \
--system \
--home /var/lib/gotify \
--shell /usr/sbin/nologin \
gotify

Create the application, configuration and data directories:

1
2
3
4
sudo install -d -o root   -g root   -m 0755 /opt/gotify
sudo install -d -o root -g gotify -m 0750 /etc/gotify
sudo install -d -o gotify -g gotify -m 0750 /var/lib/gotify
sudo install -d -o gotify -g gotify -m 0750 /var/lib/gotify/images

The binary will be owned by root in /opt/gotify. The configuration will be writable only by root and readable by the gotify group. The service account will be able to write only to its data directory.

Logs will go to journald, so I do not need another application-specific log directory to forget about during log rotation.

Install the tools needed to download, verify and unpack the release:

1
2
3
4
5
6
7
8
sudo apt update

sudo apt install --no-install-recommends \
ca-certificates \
curl \
jq \
unzip \
openssl

I assume Nginx and Certbot are already installed and working. Gotify can terminate TLS itself, but I already use Nginx for the rest of the server and prefer one public entry point.

Download and verify Gotify

Set the version and select the correct architecture:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GOTIFY_VERSION="3.0.0"

case "$(dpkg --print-architecture)" in
amd64)
GOTIFY_ARCH="amd64"
;;
arm64)
GOTIFY_ARCH="arm64"
;;
*)
echo "Unsupported architecture: $(dpkg --print-architecture)" >&2
exit 1
;;
esac

GOTIFY_ASSET="gotify-linux-${GOTIFY_ARCH}.zip"

Create a temporary working directory:

1
2
WORKDIR="$(mktemp -d)"
cd "$WORKDIR"

Download the release metadata from GitHub:

1
2
3
curl -fsSL \
"https://api.github.com/repos/gotify/server/releases/tags/v${GOTIFY_VERSION}" \
-o release.json

Read the asset URL and published SHA-256 digest:

1
2
3
4
5
6
7
8
9
10
11
12
13
GOTIFY_URL="$(
jq -r \
--arg asset "$GOTIFY_ASSET" \
'.assets[] | select(.name == $asset) | .browser_download_url' \
release.json
)"

GOTIFY_DIGEST="$(
jq -r \
--arg asset "$GOTIFY_ASSET" \
'.assets[] | select(.name == $asset) | .digest' \
release.json
)"

Stop when the asset or digest is missing:

1
2
3
4
5
6
7
8
9
10
11
12
13
test -n "$GOTIFY_URL" \
&& test "$GOTIFY_URL" != "null" \
|| {
echo "Release asset not found" >&2
exit 1
}

test -n "$GOTIFY_DIGEST" \
&& test "$GOTIFY_DIGEST" != "null" \
|| {
echo "Release digest not found" >&2
exit 1
}

Download the binary archive:

1
2
3
curl -fL \
"$GOTIFY_URL" \
-o "$GOTIFY_ASSET"

Verify it against the digest published with the GitHub release asset:

1
2
3
4
printf '%s  %s\n' \
"${GOTIFY_DIGEST#sha256:}" \
"$GOTIFY_ASSET" \
| sha256sum -c -

The result must end with:

1
OK

Do not solve a checksum mismatch by removing the checksum check. That is not troubleshooting. That is surrender.

Unpack and install the binary:

1
2
3
4
5
6
7
8
9
10
11
12
mkdir extracted

unzip -q \
"$GOTIFY_ASSET" \
-d extracted

sudo install \
-o root \
-g root \
-m 0755 \
"extracted/gotify-linux-${GOTIFY_ARCH}" \
/opt/gotify/gotify

Check the version:

1
/opt/gotify/gotify version

The output should identify Gotify 3.0.0.

Return to your home directory and remove the temporary files:

1
2
cd "$HOME"
rm -rf "$WORKDIR"

Prepare the first administrator account

Gotify creates the initial administrator when it creates a new database.

Instead of putting the bootstrap password directly into the environment file, I use the _FILE form supported by Gotify 3.

Generate a random password:

1
BOOTSTRAP_PASS="$(openssl rand -hex 16)"

Save it in a protected file:

1
2
3
4
5
printf '%s\n' "$BOOTSTRAP_PASS" \
| sudo tee /etc/gotify/bootstrap-admin.pass >/dev/null

sudo chown root:gotify /etc/gotify/bootstrap-admin.pass
sudo chmod 0640 /etc/gotify/bootstrap-admin.pass

Display it once and save it in a password manager:

1
2
printf '\nGotify username: gotifyadmin\nGotify password: %s\n\n' \
"$BOOTSTRAP_PASS"

Remove the shell variable:

1
unset BOOTSTRAP_PASS

The bootstrap values are used only while creating a new database. Editing this file later does not change the password of an existing user.

Configure Gotify 3

Gotify 3 is configured through environment variables. They can be stored in an env file, and every variable also supports a _FILE variant for reading sensitive values from a separate file.

Create /etc/gotify/server.env:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
sudo tee /etc/gotify/server.env >/dev/null <<'EOF'
GOTIFY_LOGLEVEL=info

GOTIFY_SERVER_LISTENADDR=127.0.0.1
GOTIFY_SERVER_PORT=2586
GOTIFY_SERVER_KEEPALIVEPERIODSECONDS=0

GOTIFY_SERVER_SSL_ENABLED=false
GOTIFY_SERVER_SSL_REDIRECTTOHTTPS=false

GOTIFY_SERVER_TRUSTEDPROXIES=127.0.0.1/32
GOTIFY_SERVER_SECURECOOKIE=true
GOTIFY_SERVER_STREAM_PINGPERIODSECONDS=45

GOTIFY_DATABASE_DIALECT=sqlite3
GOTIFY_DATABASE_CONNECTION=/var/lib/gotify/gotify.db

GOTIFY_DEFAULTUSER_NAME=gotifyadmin
GOTIFY_DEFAULTUSER_PASS_FILE=/etc/gotify/bootstrap-admin.pass
GOTIFY_PASSSTRENGTH=12

GOTIFY_UPLOADEDIMAGESDIR=/var/lib/gotify/images
GOTIFY_PLUGINSDIR=

GOTIFY_REGISTRATION=false
GOTIFY_OIDC_ENABLED=false

NOCOLOR=1
EOF

Protect the configuration:

1
2
sudo chown root:gotify /etc/gotify/server.env
sudo chmod 0640 /etc/gotify/server.env

The important choices are intentional:

  • Gotify binds only to 127.0.0.1.
  • Nginx handles TLS.
  • Secure session cookies are enabled because the public service uses HTTPS.
  • Only the local Nginx proxy is trusted.
  • Public registration is disabled.
  • Plugins are disabled until I have a real reason to enable them.
  • SQLite data and uploaded images are stored in /var/lib/gotify.

The goal is not to enable every feature because it exists. The goal is to run a small notification server and keep it small.

Run Gotify with systemd

The official Gotify documentation contains a minimal systemd example. I use a more restrictive service because Gotify does not require broad access to the host.

Create /etc/systemd/system/gotify.service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
sudo tee /etc/systemd/system/gotify.service >/dev/null <<'EOF'
[Unit]
Description=Gotify Push Notification Server
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=gotify
Group=gotify
WorkingDirectory=/var/lib/gotify

Environment=GOTIFY_CONFIG_FILE=/etc/gotify/server.env
Environment=TZ=Europe/Warsaw

ExecStart=/opt/gotify/gotify serve

Restart=on-failure
RestartSec=3s
UMask=0027

NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/gotify

ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectKernelLogs=true
ProtectHostname=true

RestrictSUIDSGID=true
RestrictRealtime=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true

CapabilityBoundingSet=
AmbientCapabilities=
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

LimitNOFILE=65536
TasksMax=256

[Install]
WantedBy=multi-user.target
EOF

Replace Europe/Warsaw with your own time zone. (Was it an OPSEC failure, or was the information deliberately misleading? <- very funny joke, right?)

Load the unit and start Gotify:

1
2
sudo systemctl daemon-reload
sudo systemctl enable --now gotify

Check the status and logs:

1
2
sudo systemctl status gotify --no-pager
sudo journalctl -u gotify -n 100 --no-pager

Confirm that the service listens only on localhost:

1
sudo ss -ltnp | grep ':2586 '

The important part should look like this:

1
127.0.0.1:2586

It should not show:

1
0.0.0.0:2586

Test the local service:

1
2
curl -fsS http://127.0.0.1:2586/ >/dev/null \
&& echo "Gotify local HTTP: OK"

The first start should create the SQLite database:

1
sudo ls -lh /var/lib/gotify

You should see gotify.db.

Put Nginx in front of Gotify

Gotify uses WebSockets for real-time message delivery. The reverse proxy must support the WebSocket upgrade and preserve the original Host header because Gotify verifies the host against the request origin.

Create an HTTP virtual host first:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
sudo tee /etc/nginx/sites-available/notify.conf >/dev/null <<'EOF'
upstream gotify_notify_backend {
server 127.0.0.1:2586;
keepalive 8;
}

server {
listen 80;
listen [::]:80;

server_name notify.example.com;

location / {
proxy_pass http://gotify_notify_backend;
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_redirect http:// $scheme://;
proxy_buffering off;

proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
EOF

Enable the site:

1
2
3
sudo ln -sfn \
/etc/nginx/sites-available/notify.conf \
/etc/nginx/sites-enabled/notify.conf

Test the configuration and reload Nginx:

1
2
sudo nginx -t
sudo systemctl reload nginx

Do not continue when nginx -t reports an error. Nginx is very honest about configuration problems, which is a feature I appreciate more every time I edit a production server.

Create the DNS record for notify.example.com, point it to the server and request a certificate:

1
sudo certbot --nginx -d notify.example.com

After Certbot finishes, check the resulting configuration. My final HTTPS virtual host looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
upstream gotify_notify_backend {
server 127.0.0.1:2586;
keepalive 8;
}

server {
listen 80;
listen [::]:80;

server_name notify.example.com;

return 301 https://$host$request_uri;
}

server {
listen 443 ssl http2;
listen [::]:443 ssl http2;

server_name notify.example.com;

ssl_certificate /etc/letsencrypt/live/notify.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/notify.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

client_max_body_size 10m;

add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "same-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;

location / {
proxy_pass http://gotify_notify_backend;
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_redirect http:// $scheme://;
proxy_buffering off;

proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}

Test and reload Nginx again:

1
2
sudo nginx -t
sudo systemctl reload nginx

Test the public service:

1
curl -I https://notify.example.com

Do not expose port 2586 in the firewall. Only ports 80 and 443 should be publicly reachable for the web service.

When using Cloudflare, first confirm that the direct setup works. Then enable the proxy and use Full (strict) TLS mode. Adding another layer while troubleshooting the first layer is a reliable way to spend an evening staring at error pages.

First login

Open:

1
https://notify.example.com

Log in with:

1
2
Username: gotifyadmin
Password: the generated bootstrap password

Gotify 3 introduced session elevation for sensitive actions. The WebUI may ask you to confirm your password again before changing credentials, deleting security-sensitive objects or rotating tokens. This is normal.

Change the administrator password in the WebUI.

After confirming the new password works, replace the old bootstrap value stored on disk:

1
2
3
4
5
openssl rand -hex 32 \
| sudo tee /etc/gotify/bootstrap-admin.pass >/dev/null

sudo chown root:gotify /etc/gotify/bootstrap-admin.pass
sudo chmod 0640 /etc/gotify/bootstrap-admin.pass

Restart Gotify:

1
2
sudo systemctl restart gotify
sudo systemctl status gotify --no-pager

This does not change the password stored in the existing database. It only prevents the original bootstrap password from remaining in a file forever.

Create a test application

Open the Apps section and create an application:

1
2
Name: Test
Description: Temporary Gotify installation test

Save it and immediately copy the token.

Gotify 3 displays a token only during creation or rotation. Store production tokens in a password manager or a protected secret file. Do not paste them into screenshots, tickets, chat messages or a public repository. This sounds obvious because it is obvious, but GitHub still receives secrets every day.

Read the token into a temporary variable without placing it directly in shell history:

1
2
read -rsp "Paste the Test application token: " GOTIFY_TOKEN
echo

Send a test message:

1
2
3
4
5
6
curl -fsS \
"https://notify.example.com/message" \
-H "X-Gotify-Key: ${GOTIFY_TOKEN}" \
-F "title=Gotify Test" \
-F "message=My self-hosted notification server works." \
-F "priority=5"

Remove the variable:

1
unset GOTIFY_TOKEN

The message should immediately appear in the WebUI.

Only message is required by the API, but a title and priority make operational alerts much more useful. Priority also affects how clients present a notification, including sound behaviour in the Android application.

Connect the Android application

Install the official Gotify Android application from Google Play or F-Droid.

Add the server URL:

1
https://notify.example.com

Log in with your Gotify user account.

That is all. There is no topic name to guess and no channel to subscribe to. Applications created by the user are visible to that user, and incoming messages appear in the WebUI and the Android client.

At this point I spent a few minutes sending messages to myself with different titles and priorities because apparently receiving notifications from my own server is entertainment now.

Connect Uptime Kuma

Uptime Kuma has a native Gotify notification provider.

Create a separate application in Gotify:

1
2
Name: Uptime Kuma
Description: Availability and recovery alerts

Copy the new application token immediately.

In Uptime Kuma:

  • open Settings;
  • open Notifications;
  • create a new notification;
  • select Gotify;
  • enter the application token;
  • enter the public Gotify server URL;
  • select a priority from 0 to 10;
  • use the built-in test button.

A typical configuration looks like this:

1
2
3
4
Notification type: Gotify
Server URL: https://notify.example.com
Application Token: A...redacted...
Priority: 8

Attach the notification provider to the monitors that should use it.

My availability alerts now arrive through Gotify. When a monitored service goes down, I receive the problem notification. When it recovers, I receive the recovery message. Discord is no longer involved.

My Uptime Kuma and Gotify instances currently live on the same small monitoring VPS. This creates an obvious limitation: when the entire VPS disappears, Gotify cannot send a message explaining that Gotify disappeared.

Fixing this requires another monitor, which then needs another notification path, which then needs another monitor. This is how infrastructure turns into a Russian nesting doll.

For my current use case, an external check of the public status endpoint is enough. The important part is understanding the limitation instead of pretending it does not exist.

Use a separate application for every service

I do not use one universal token for the whole infrastructure.

My applications are organised more like this:

1
2
3
4
5
6
7
Uptime Kuma
Hack Hub
VPS - 0ut3r
VPS - Bounty
VPS - Uptime
Pulsar Relay
Backups

This provides useful separation:

  • the message source is immediately visible;
  • priorities can differ between services;
  • a compromised token affects only one application;
  • one integration can be disabled without touching the others;
  • application history remains easier to read.

An application token can send messages. A user or client account can read them. Treat those as different roles and do not place a client token into a server-side script when an application token is all you need.

Add Gotify to a shell script

A minimal shell integration looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env bash

set -euo pipefail

GOTIFY_URL="https://notify.example.com"
GOTIFY_TOKEN_FILE="/etc/my-service/gotify.token"

GOTIFY_TOKEN="$(<"$GOTIFY_TOKEN_FILE")"

curl \
--fail \
--silent \
--show-error \
--connect-timeout 5 \
--max-time 15 \
--retry 2 \
"${GOTIFY_URL}/message" \
-H "X-Gotify-Key: ${GOTIFY_TOKEN}" \
-F "title=Backup failed" \
-F "message=The daily backup did not complete." \
-F "priority=10" \
>/dev/null

Create and protect the token file:

1
2
3
4
5
6
7
8
9
10
sudo install -d \
-o root \
-g root \
-m 0700 \
/etc/my-service

sudo nano /etc/my-service/gotify.token

sudo chown root:root /etc/my-service/gotify.token
sudo chmod 0600 /etc/my-service/gotify.token

The token is not placed in the process arguments, source code or public configuration.

The same API can be used from PHP, Python, Go, Rust or anything else capable of sending an HTTP request.

Hack Hub notifications

I added a separate Hack Hub application in Gotify and stored its token only in Hack Hub’s protected environment configuration.

The application uses values equivalent to:

1
2
3
GOTIFY_SERVER_URL=https://notify.example.com
GOTIFY_APP_TOKEN=A_REDACTED_APPLICATION_TOKEN
GOTIFY_PRIORITY=5

The token must never be committed to the repository or included in a release archive.

A simplified PHP sender looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?php

declare(strict_types=1);

final class GotifyNotifier
{
public function __construct(
private readonly string $serverUrl,
private readonly string $applicationToken,
private readonly int $defaultPriority = 5,
) {
}

public function send(
string $title,
string $message,
?int $priority = null,
): void {
if ($this->serverUrl === '' || $this->applicationToken === '') {
return;
}

$payload = json_encode(
[
'title' => $title,
'message' => $message,
'priority' => $priority ?? $this->defaultPriority,
],
JSON_THROW_ON_ERROR,
);

$handle = curl_init(
rtrim($this->serverUrl, '/') . '/message',
);

if ($handle === false) {
throw new RuntimeException('Unable to initialize Gotify request.');
}

curl_setopt_array(
$handle,
[
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Gotify-Key: ' . $this->applicationToken,
],
CURLOPT_POSTFIELDS => $payload,
],
);

$response = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);

curl_close($handle);

if ($response === false || $status < 200 || $status >= 300) {
throw new RuntimeException(
sprintf(
'Gotify request failed with HTTP %d: %s',
$status,
$error !== '' ? $error : 'unexpected response',
),
);
}
}
}

The real application wraps notification failures so a temporary Gotify outage does not break a user registration or another primary application action. Notifications are useful, but the notification server should not become a required dependency for submitting a resource.

Hack Hub sends only administrative events that may require attention:

1
2
3
4
5
6
New user registration
New resource submission
New update suggestion
New report
New contact message
Selected operational failures

I deliberately do not send notifications for every successful request, background job or page view.

A notification system becomes useless when every part of the application wants attention. I do not need my phone congratulating me because a cron job did the thing it was created to do.

The admin panel also includes a test action so I can verify the current URL and token after deployment without creating a fake registration or waiting for a real report.

Pick priorities that mean something

Gotify accepts message priorities from 0 to 10. The number should represent urgency rather than the developer’s enthusiasm.

My general model is:

1
2
3
4
3  informational or recovery
5 warning or action needed later
8 important failure
10 critical and immediate attention

Examples:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Priority 3:
Backup completed after a previous failure
A service recovered
A maintenance action finished

Priority 5:
New Hack Hub submission
New user registration
Reboot required
Disk usage crossed a warning threshold

Priority 8:
Uptime Kuma detected a service outage
Unattended upgrades failed
A critical service stopped

Priority 10:
Filesystem is almost full
Database is unavailable
Repeated backup failures
A security-critical condition needs immediate action

The exact numbers are less important than using them consistently.

Everything becomes critical when there is no policy. When everything is critical, nothing is critical. It is the monitoring equivalent of writing an entire email in capital letters.

Do not turn Gotify into another source of noise

The technical integration is easy. The difficult part is deciding what deserves a notification.

Useful notifications usually represent a state change:

1
2
3
4
5
Healthy -> Warning
Healthy -> Critical
Warning -> Critical
Warning -> Healthy
Critical -> Healthy

Repeated messages about the same unchanged failure should be deduplicated or delayed.

For example, a disk at 96% should create a critical notification. It should not send the same critical notification every five minutes for the next three days. A reminder after several hours may be useful. Sixty identical messages are not.

Recovery notifications are important because otherwise I receive a problem, repair it and then still need to open another dashboard to confirm the result.

My preferred model is:

1
2
3
4
5
Send once when the problem begins.
Escalate when it becomes worse.
Remind only after a reasonable delay.
Send once when it recovers.
Stay quiet while everything is healthy.

This is also the model I plan to use in a separate VPS agent that will monitor Debian hosts and send useful system alerts to Gotify. That project deserves its own article when it exists and has survived contact with real servers.

Logs and basic checks

Gotify logs are available through journald:

1
sudo journalctl -u gotify

Show recent logs:

1
2
3
4
sudo journalctl \
-u gotify \
--since "15 minutes ago" \
--no-pager

Follow logs in real time:

1
sudo journalctl -u gotify -f

Check the services:

1
2
3
sudo systemctl is-enabled gotify
sudo systemctl is-active gotify
sudo systemctl is-active nginx

Check the relevant listeners:

1
2
sudo ss -ltnp \
| grep -E ':(80|443|2586)\b'

Nginx should listen publicly on ports 80 and 443. Gotify should listen only on 127.0.0.1:2586.

Test both paths:

1
2
3
4
5
curl -fsS http://127.0.0.1:2586/ >/dev/null \
&& echo "Local Gotify: OK"

curl -fsS https://notify.example.com/ >/dev/null \
&& echo "Public Gotify: OK"

Check resource usage:

1
2
3
4
ps -C gotify \
-o pid,user,%cpu,%mem,rss,cmd

free -h

Gotify is small and quiet when there are no messages. It does not need a large server for a private installation with a few services.

Review the systemd sandbox

Systemd can show a security-oriented assessment of the service unit:

1
systemd-analyze security gotify.service

This is not a penetration test and the score is not a universal truth. It is still useful for finding permissions or namespace access that can be restricted further.

When changing the unit, test the service immediately:

1
2
3
4
sudo systemctl daemon-reload
sudo systemctl restart gotify
sudo systemctl status gotify --no-pager
sudo journalctl -u gotify -n 100 --no-pager

Hardening that prevents the application from starting is not security. It is downtime with good intentions.

Back up Gotify

With SQLite, the important data lives under:

1
/var/lib/gotify

The configuration lives under:

1
/etc/gotify

Both should be included in backups.

For a simple consistent backup, briefly stop the service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
sudo install -d \
-o root \
-g root \
-m 0700 \
/var/backups/gotify

BACKUP="/var/backups/gotify/gotify-$(date +%F-%H%M%S).tar.gz"

sudo systemctl stop gotify

sudo tar \
-C / \
-czf "$BACKUP" \
var/lib/gotify \
etc/gotify \
etc/systemd/system/gotify.service \
etc/nginx/sites-available/notify.conf

sudo systemctl start gotify

sudo systemctl is-active --quiet gotify
sudo ls -lh "$BACKUP"

For my small private instance, the interruption lasts only a moment. A more critical installation can use a SQLite-aware online backup or another supported database.

The database contains users, applications, clients, messages and protected token data. The configuration and bootstrap secret are also sensitive. Protect the backup like the server itself.

A backup that anyone can download is not a backup. It is an export feature.

Update the standalone binary

A standalone installation is not updated by APT. I need to watch the Gotify releases and replace the binary manually.

Read the release notes first, especially for major versions. Gotify 3 removed the old YAML configuration format and changed token handling, which is exactly the kind of detail that turns a blind binary replacement into an educational outage.

The safe update flow is:

1
2
3
4
5
6
7
8
Read release notes
Back up data and configuration
Download the correct asset
Verify the published digest
Stop Gotify
Replace the binary
Start Gotify
Check the version, logs and WebUI

After downloading and verifying a new release as shown earlier:

1
2
3
4
5
6
7
8
9
10
sudo systemctl stop gotify

sudo install \
-o root \
-g root \
-m 0755 \
"extracted/gotify-linux-${GOTIFY_ARCH}" \
/opt/gotify/gotify

sudo systemctl start gotify

Verify the result:

1
2
3
4
5
6
7
/opt/gotify/gotify version

sudo systemctl status gotify --no-pager
sudo journalctl -u gotify -n 100 --no-pager

curl -fsS https://notify.example.com/ >/dev/null \
&& echo "Gotify update: OK"

Send a test notification after the upgrade. A green service status confirms that a process is running. It does not confirm that application tokens, WebSockets and the Android client still work.

Removing the standalone installation

Stop and disable the service:

1
sudo systemctl disable --now gotify

Remove the unit and binary:

1
2
3
4
5
6
sudo rm -f \
/etc/systemd/system/gotify.service \
/opt/gotify/gotify

sudo systemctl daemon-reload
sudo systemctl reset-failed

Remove the Nginx site only when it is dedicated to Gotify:

1
2
3
4
5
6
sudo rm -f \
/etc/nginx/sites-enabled/notify.conf \
/etc/nginx/sites-available/notify.conf

sudo nginx -t
sudo systemctl reload nginx

The following command permanently removes the database, users, messages and configuration:

1
2
3
4
sudo rm -rf \
/var/lib/gotify \
/etc/gotify \
/opt/gotify

Do not run it merely because it appears in an article. Confirm your backups and confirm the paths first.

Remove the service user after deleting the data:

1
sudo userdel gotify

Certificates and DNS records should be reviewed separately. They may still be used by another service or retained for migration.

Was it worth it?

Yes.

Discord webhooks were convenient and did their job, but I did not need a complete external chat platform for private infrastructure notifications.

Gotify gives me:

  • a small self-hosted server;
  • a clean WebUI;
  • an Android client;
  • separate application tokens;
  • real-time delivery through WebSockets;
  • a simple REST API;
  • native integration with tools such as Uptime Kuma;
  • an easy path for adding notifications to my own applications.

It is also not a custom notification script I will forget how to maintain next month. Gotify is an established open-source project and many self-hosted applications already support it. When native support is missing, the HTTP API is simple enough to integrate directly.

Self-hosting still means another service to update, monitor, back up and occasionally repair. That is the price of owning the solution.

On the other hand, the notification history stays on my server. I decide which applications can send messages, how tokens are separated and which events deserve my attention.

For me, that is a fair trade.

That’s all folks. May your servers stay online and your phone remain silent, because the best infrastructure notification is the one that never has to arrive.