Background

Mobile notifications—particularly on Android—are challenging to implement. There are 3 methods for app to send notifications in the background:

  • Firebase Cloud Messaging-
  • UnifiedPush
  • proprietary long‑polling connection.

For battery‑life considerations, the proprietary long‑polling approach is undesirable. Consequently, we will concentrate on a push‑based architecture that employs a notification distributor and server. We are eliminating Firebase Cloud Messaging from our sight due to the Google dependency and privacy controversy, and have the crosshair directly on UnifiedPush.

Here’s a flow chart for how notifications work in Matrix via Sunup (bare with my poor calligraphy please!):

Flow

You can see that, in the scope of Matrix notifications from clients like Element X, a message is generated passing the following flow:

  1. The homeserver (Synapse, Dendrite, Continuwuity) processes events constantly. For each incoming event the server evaluates the configured push rules, (specifically the m.mentions entries, see Intentional Mentions MSC), to determine whether the event satisfies any rule. If a match is found, the server triggers the corresponding push notification workflow.

  2. When an event matches a push rule, the homeserver sends a notification to the Matrix Push Gateway, which is common-proxies in our case.

  3. The Matrix Push Gateway (Common Proxies) rewrites received notification data, and sends it to user-specified push endpoint (autopush autoend endpoint).

  4. After receiving the notification data. The push endpoint notifies a push server (autopush autoconnect) about this new notification via Valkey.

    • Side note here: If you are using regular UnifiedPush applications, this is the starting point. Matrix does not support UnifiedPush natively so we need a translator.
  5. The push server sends down notification data to your phone, to Sunup.

  6. Sunup broadcasts the pertinent message to the target application, brings the application to the foreground, and then allows it to retrieve the required data and issue the final Android notification.

    • We selected Sunup because Ntfy does not function correctly on our Android 16 phones. It fails to wake the target application, preventing reliable notification delivery.
  7. You see the notification.

Self Hosting

Before reading

  • Replace every domain name that appears in the article with your own domain.
  • Text enclosed in [] indicates a placeholder you must fill in yourself
  • I’m using a package to store configurations. Please use /etc rather than /usr if you’re not.
  • This article assumes you have basic levels of knowledge of systemd, and is using a FHS-compliant system (i.e., not NixOS). Adjust file locations accordingly if your system uses a different layout.

Required Server Packages (Arch Linux)

Package Purpose
valkey In‑memory key‑value store used by the autopush infrastructure.
autopush‑rs‑p1gp1g‑git Actual push server, with Redis support
common‑proxies Matrix Push Gateway
pwgen Generates random passwords for configuration files.
portable (optional) Provides isolated shell; install on a workstation rather than the production server.

Note that p1gp1g’s fork of autopush-rs is required because upstream hasn’t merged Redis support, and we don’t want to rely on Google BigTable. Valkey is the replacement of Redis.

If you aren’t using Arch Linux, those build scripts are publicly available either on Arch GitLab or AUR

Valkey

Valkey acts like a middleman. It helps autopush autoend and autoconnect to communicate.

Copy an example Valkey configuration file. Note: when you change configurations, make sure there’s no default entry overriding your config.

You may want to disable Valkey’s snapshot, and instead write everything to disk for stability. See Documentation: Persistence for more info.

We are just gonna disable RDB and enable AOF here:

1
2
3
4
5
save ""
appendonly yes
appendfilename "appendonly.aof"
aof-timestamp-enabled no
appendfsync no

Next, generate a password for your autopush-rs server:

1
pwgen -s 64 1

Copy it down, save it in a secure place.

Add autopush user to Valkey:

1
user autopush on +@all -DEBUG ~* >[your DB password here]

Final step: you can install the configuration file to /usr/share/serverOS/valkey.conf. Mind the permission should be 0700 and owned by root.

And now, create the systemd service /usr/lib/systemd/serverOS-valkey.service for Valkey with the following contents:

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
[Unit]
Description=Advanced key-value store
After=network.target
Conflicts=valkey.service
StartLimitIntervalSec=120
StartLimitBurst=10
RequiresMountsFor=/var/lib/private/serverOS-valkey

[Service]
OOMPolicy=stop
LoadCredential=valkey.conf:/usr/share/serverOS/valkey.conf
CPUWeight=50
StateDirectory=serverOS-valkey
StateDirectoryMode=0700
UMask=077
Restart=always
RestartSec=10s
Slice=serverOS.slice
IPAccounting=yes
IPAddressAllow=link-local localhost
MemoryHigh=10G
Type=notify
DynamicUser=yes
ExecStart=/usr/bin/valkey-server "${CREDENTIALS_DIRECTORY}"/valkey.conf
TimeoutStartSec=60
TimeoutStopSec=60
CapabilityBoundingSet=
PrivateTmp=disconnected
PrivateDevices=true
ProtectSystem=full
ProtectHome=yes
NoNewPrivileges=true
RuntimeDirectory=valkey
RuntimeDirectoryMode=755
LimitNOFILE=10032
ProtectHostname=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RemoveIPC=yes
SystemCallArchitectures=native

ProtectProc=invisible
ProcSubset=pid

SystemCallFilter=~@clock
SystemCallFilter=~@cpu-emulation
SystemCallFilter=~@debug
SystemCallFilter=~@module
SystemCallFilter=~@mount
SystemCallFilter=~@obsolete
SystemCallFilter=~@raw-io
SystemCallFilter=~@reboot
SystemCallFilter=~@swap
SystemCallErrorNumber=EPERM

PrivateUsers=self

[Install]
WantedBy=multi-user.target
Alias=redis.service

Reload systemd to pick up the new unit file:

1
systemctl daemon-reload

Enable the service and start it immediately:

1
systemctl enable --now serverOS-valkey

Autopush-rs

Obtain keys

On your desktop system, execute portable-pools build to create a build sandbox (optional).

Then execute git clone --depth 1 https://github.com/p1gp1g/autopush-rs to get the Redis fork.

Once finished, run cd autopush-rs && python -m pip install cryptography --break-system-packages to get dependencies.

Now, you can generate an autopush key with python scripts/fernet_key.py. Save it somewhere safe.

Use exit or Control + D to exit out.

Service configuration

Create /usr/share/serverOS/autopush-rs.env with permission 0700 and owner root, and write the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RUST_BACKTRACE=1

# Autoconnect
AUTOCONNECT__DB_DSN=redis://autopush:[your DB password here]@localhost:6379
AUTOCONNECT__CRYPTO_KEY="[[your autopush key here]]"
AUTOCONNECT__ENDPOINT_SCHEME=https
AUTOCONNECT__ENDPOINT_HOSTNAME=updates.[your push subdomain here]
AUTOCONNECT__ENDPOINT_PORT=443
AUTOCONNECT__HOSTNAME=localhost

# Autoendpoint
AUTOEND__DB_DSN=redis://autopush:[your DB password here]@localhost:6379
AUTOEND__CRYPTO_KEYS="[[your autopush key here]]"
AUTOEND__HOST=127.0.0.5
AUTOEND__PORT=31451
AUTOEND__ENDPOINT_URL=https://[your push subdomain here]
AUTOEND__HUMAN_LOGS=true

Create /usr/lib/systemd/system/serverOS-autoconnect.service and /usr/lib/systemd/system/serverOS-autoendpoint.service respectively:

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
[Unit]
Description=Autopush autoconnect
After=serverOS-valkey.service
StartLimitIntervalSec=120
StartLimitBurst=10
Wants=serverOS-autoendpoint.service
Requires=serverOS-valkey.service

[Service]
MemoryDenyWriteExecute=yes
OOMPolicy=stop
EnvironmentFile=/usr/share/serverOS/autopush-rs.env
CPUWeight=150
StateDirectoryMode=0700
UMask=077
Restart=always
RestartSec=10s
Slice=serverOS.slice
IPAccounting=yes
MemoryHigh=2G
DynamicUser=yes
ExecStart=/usr/bin/autoconnect
TimeoutStartSec=60
TimeoutStopSec=60
CapabilityBoundingSet=
PrivateTmp=disconnected
PrivateDevices=true
ProtectSystem=full
ProtectHome=yes
NoNewPrivileges=true
RuntimeDirectoryMode=755
LimitNOFILE=10032
ProtectHostname=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RemoveIPC=yes
SystemCallArchitectures=native

ProtectProc=invisible
ProcSubset=pid

SystemCallFilter=~@clock
SystemCallFilter=~@cpu-emulation
SystemCallFilter=~@debug
SystemCallFilter=~@module
SystemCallFilter=~@mount
SystemCallFilter=~@obsolete
SystemCallFilter=~@raw-io
SystemCallFilter=~@reboot
SystemCallFilter=~@swap
SystemCallErrorNumber=EPERM

PrivateUsers=self

[Install]
WantedBy=multi-user.target

and…

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
[Unit]
Description=Autopush autoendpoint
After=serverOS-valkey.service
StartLimitIntervalSec=120
StartLimitBurst=10
Requires=serverOS-valkey.service

[Service]
MemoryDenyWriteExecute=yes
OOMPolicy=stop
EnvironmentFile=/usr/share/serverOS/autopush-rs.env
CPUWeight=150
StateDirectoryMode=0700
UMask=077
Restart=always
RestartSec=10s
Slice=serverOS.slice
IPAccounting=yes
MemoryHigh=2G
DynamicUser=yes
ExecStart=/usr/bin/autoendpoint
TimeoutStartSec=60
TimeoutStopSec=60
CapabilityBoundingSet=
PrivateTmp=disconnected
PrivateDevices=true
ProtectSystem=full
ProtectHome=yes
NoNewPrivileges=true
RuntimeDirectoryMode=755
LimitNOFILE=10032
ProtectHostname=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RemoveIPC=yes
SystemCallArchitectures=native

ProtectProc=invisible
ProcSubset=pid

SystemCallFilter=~@clock
SystemCallFilter=~@cpu-emulation
SystemCallFilter=~@debug
SystemCallFilter=~@module
SystemCallFilter=~@mount
SystemCallFilter=~@obsolete
SystemCallFilter=~@raw-io
SystemCallFilter=~@reboot
SystemCallFilter=~@swap
SystemCallErrorNumber=EPERM

PrivateUsers=self

[Install]
WantedBy=multi-user.target

Reload systemd to recognize any new unit files:

1
systemctl daemon-reload

Enable and start the two services immediately:

1
systemctl enable --now serverOS-autoconnect serverOS-autoendpoint

Common Proxies

Create configuration file /usr/share/serverOS/common-proxies.toml with permission 0700 and owner root:

1
2
3
4
5
6
7
8
9
10
11
12
listenAddr = "127.0.0.100:5000"
verbose = false

[gateway]
AllowedHosts = ["abc.localhost:8443", "abc.localhost:8080", "myinternaldomain.local"]
[gateway.matrix]
enabled = true
[gateway.aesgcm]
enabled = true
[rewrite]
[rewrite.webpushfcm]
enabled = false

Note that you should add your autoend endpoint updates.[your push subdomain here] and Matrix server to AllowedHosts

Create the systemd service for common-proxies: /usr/lib/systemd/system/serverOS-common-proxies.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
52
53
54
55
56
57
58
59
60
61
[Unit]
Description=serverOS Matrix Push Gateway
After=serverOS-autoconnect.service
StartLimitIntervalSec=120
StartLimitBurst=10

[Service]
LoadCredential=config.toml:/usr/share/serverOS/common-proxies.toml
MemoryDenyWriteExecute=yes
OOMPolicy=stop
CPUWeight=150
StateDirectoryMode=0700
UMask=077
Restart=always
RestartSec=10s
Slice=serverOS.slice
IPAccounting=yes
MemoryHigh=200M
DynamicUser=yes
ExecStart=/usr/bin/common-proxies -c "${CREDENTIALS_DIRECTORY}"/config.toml
TimeoutStartSec=60
TimeoutStopSec=60
CapabilityBoundingSet=
PrivateTmp=disconnected
PrivateDevices=true
ProtectSystem=full
ProtectHome=yes
NoNewPrivileges=true
RuntimeDirectoryMode=755
LimitNOFILE=10032
ProtectHostname=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RemoveIPC=yes
SystemCallArchitectures=native

ProtectProc=invisible
ProcSubset=pid

SystemCallFilter=~@clock
SystemCallFilter=~@cpu-emulation
SystemCallFilter=~@debug
SystemCallFilter=~@module
SystemCallFilter=~@mount
SystemCallFilter=~@obsolete
SystemCallFilter=~@raw-io
SystemCallFilter=~@reboot
SystemCallFilter=~@swap
SystemCallErrorNumber=EPERM

PrivateUsers=self

[Install]
WantedBy=multi-user.target

Reload systemd to recognize any new unit files:

1
systemctl daemon-reload

Enable and start the service immediately:

1
systemctl enable --now serverOS-common-proxies

Reverse Proxy

Here comes the part of reverse proxy. I’m using Nginx as an example, but it should be the same for other software like Caddy.

Create a new server block for your autoend endpoint (This is a simplified example, it will not work!):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server {
...
server_name updates.[your push subdomain here];
...
location /_matrix/push/v1/notify {
proxy_pass http://127.0.0.100:5000;
}
location / {
proxy_pass http://127.0.0.5:31451;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_connect_timeout 3m;
proxy_send_timeout 3m;
proxy_read_timeout 3m;
client_max_body_size 0;
}
}

for autoconnect:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server {
...
server_name [your push subdomain here];
...
location ~ ^/(wpfcm|_matrix) {
proxy_pass http://127.0.0.100:5000;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 3m;
proxy_send_timeout 3m;
proxy_read_timeout 3m;
client_max_body_size 0;
proxy_buffering off;
proxy_request_buffering off;
proxy_redirect off;
}
}

That’s it. You then obtain TLS certificates, restart Nginx and set the push server in Sunup. And notifications will be working, well, if you did the right thing.