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 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 | Website, server or script |
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 | Internet |
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 | getent passwd gotify >/dev/null || sudo useradd \ |
Create the application, configuration and data directories:
1 | sudo install -d -o root -g root -m 0755 /opt/gotify |
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 | sudo apt update |
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 | GOTIFY_VERSION="3.0.0" |
Create a temporary working directory:
1 | WORKDIR="$(mktemp -d)" |
Download the release metadata from GitHub:
1 | curl -fsSL \ |
Read the asset URL and published SHA-256 digest:
1 | GOTIFY_URL="$( |
Stop when the asset or digest is missing:
1 | test -n "$GOTIFY_URL" \ |
Download the binary archive:
1 | curl -fL \ |
Verify it against the digest published with the GitHub release asset:
1 | printf '%s %s\n' \ |
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 | mkdir extracted |
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 | cd "$HOME" |
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 | printf '%s\n' "$BOOTSTRAP_PASS" \ |
Display it once and save it in a password manager:
1 | printf '\nGotify username: gotifyadmin\nGotify password: %s\n\n' \ |
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 | sudo tee /etc/gotify/server.env >/dev/null <<'EOF' |
Protect the configuration:
1 | sudo chown root:gotify /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 | sudo tee /etc/systemd/system/gotify.service >/dev/null <<'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 | sudo systemctl daemon-reload |
Check the status and logs:
1 | sudo systemctl status gotify --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 | curl -fsS http://127.0.0.1:2586/ >/dev/null \ |
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 | sudo tee /etc/nginx/sites-available/notify.conf >/dev/null <<'EOF' |
Enable the site:
1 | sudo ln -sfn \ |
Test the configuration and reload Nginx:
1 | sudo nginx -t |
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 | upstream gotify_notify_backend { |
Test and reload Nginx again:
1 | sudo nginx -t |
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 | Username: gotifyadmin |
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 | openssl rand -hex 32 \ |
Restart Gotify:
1 | sudo systemctl restart gotify |
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 | Name: 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 | read -rsp "Paste the Test application token: " GOTIFY_TOKEN |
Send a test message:
1 | curl -fsS \ |
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 | Name: Uptime Kuma |
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
0to10; - use the built-in test button.
A typical configuration looks like this:
1 | Notification type: Gotify |
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 | Uptime Kuma |
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 | #!/usr/bin/env bash |
Create and protect the token file:
1 | sudo install -d \ |
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 | GOTIFY_SERVER_URL=https://notify.example.com |
The token must never be committed to the repository or included in a release archive.
A simplified PHP sender looks like this:
1 | <?php |
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 | New user registration |
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 | 3 informational or recovery |
Examples:
1 | Priority 3: |
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 | Healthy -> Warning |
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 | Send once when the problem begins. |
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 | sudo journalctl \ |
Follow logs in real time:
1 | sudo journalctl -u gotify -f |
Check the services:
1 | sudo systemctl is-enabled gotify |
Check the relevant listeners:
1 | sudo ss -ltnp \ |
Nginx should listen publicly on ports 80 and 443. Gotify should listen only on 127.0.0.1:2586.
Test both paths:
1 | curl -fsS http://127.0.0.1:2586/ >/dev/null \ |
Check resource usage:
1 | ps -C gotify \ |
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 | sudo systemctl daemon-reload |
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 | sudo install -d \ |
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 | Read release notes |
After downloading and verifying a new release as shown earlier:
1 | sudo systemctl stop gotify |
Verify the result:
1 | /opt/gotify/gotify version |
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 | sudo rm -f \ |
Remove the Nginx site only when it is dedicated to Gotify:
1 | sudo rm -f \ |
The following command permanently removes the database, users, messages and configuration:
1 | sudo rm -rf \ |
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.