diff --git a/.gitignore b/.gitignore
index edeaf048bc80b0783461a08257aa51fc62e6d788..a4422373e971f72a179eb0f88a7e7a2a47456600 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 dist/
 build/
 .idea/
+*.swp
 server/docs/
 server/site/
 tools/fbsend/fbsend
diff --git a/cmd/serve_linux.go b/cmd/serve_linux.go
index b56268b499ee56239dfde904f748ebcc5f72ecf9..0d83866512c197905eb3d3cb4e47edff73c3db81 100644
--- a/cmd/serve_linux.go
+++ b/cmd/serve_linux.go
@@ -3,15 +3,16 @@ package cmd
 import (
 	"errors"
 	"fmt"
-	"github.com/urfave/cli/v2"
-	"github.com/urfave/cli/v2/altsrc"
-	"heckel.io/ntfy/server"
-	"heckel.io/ntfy/util"
 	"log"
 	"math"
 	"net"
 	"strings"
 	"time"
+
+	"github.com/urfave/cli/v2"
+	"github.com/urfave/cli/v2/altsrc"
+	"heckel.io/ntfy/server"
+	"heckel.io/ntfy/util"
 )
 
 func init() {
@@ -146,8 +147,10 @@ func execServe(c *cli.Context) error {
 		return errors.New("if set, web-root must be 'home' or 'app'")
 	}
 
-	// Default auth permissions
 	webRootIsApp := webRoot == "app"
+	enableWeb := webRoot != "disable"
+
+	// Default auth permissions
 	authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
 	authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
 
@@ -227,6 +230,7 @@ func execServe(c *cli.Context) error {
 	conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
 	conf.BehindProxy = behindProxy
+	conf.EnableWeb = enableWeb
 	s, err := server.New(conf)
 	if err != nil {
 		log.Fatalln(err)
diff --git a/docs/config.md b/docs/config.md
index 448efddfc1d1b743ba1999058701b33c7f2d47db..59d600e4679225baecf8354b5b637390d844ca10 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -802,7 +802,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
 | `smtp-server-addr-prefix`                  | `NTFY_SMTP_SERVER_ADDR_PREFIX`                  | `[ip]:port`                                         | -            | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-`                                                                                                                                                          |
 | `keepalive-interval`                       | `NTFY_KEEPALIVE_INTERVAL`                       | *duration*                                          | 45s          | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
 | `manager-interval`                         | `$NTFY_MANAGER_INTERVAL`                        | *duration*                                          | 1m           | Interval in which the manager prunes old messages, deletes topics and prints the stats.                                                                                                                                         |
-| `web-root`                                 | `NTFY_WEB_ROOT`                                 | `app` or `home`                                     | `app`        | Sets web root to landing page (home) or web app (app)                                                                                                                                                                           |
+| `web-root`                                 | `NTFY_WEB_ROOT`                                 | `app` or `home`                                     | `app`        | Sets web root to landing page (home), web app (app) or (disable) for no WebUI.                                                                                                                                                  |
 | `global-topic-limit`                       | `NTFY_GLOBAL_TOPIC_LIMIT`                       | *number*                                            | 15,000       | Rate limiting: Total number of topics before the server rejects new topics.                                                                                                                                                     |
 | `visitor-subscription-limit`               | `NTFY_VISITOR_SUBSCRIPTION_LIMIT`               | *number*                                            | 30           | Rate limiting: Number of subscriptions per visitor (IP address)                                                                                                                                                                 |
 | `visitor-attachment-total-size-limit`      | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT`      | *size*                                              | 100M         | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`.                                                 |
diff --git a/server/config.go b/server/config.go
index e866e17ac2896f1dfe9c9c46a1fa4a15bd77181b..ea34c6afe47e61de4d81185fe08e5e46485554cd 100644
--- a/server/config.go
+++ b/server/config.go
@@ -88,6 +88,7 @@ type Config struct {
 	VisitorEmailLimitBurst               int
 	VisitorEmailLimitReplenish           time.Duration
 	BehindProxy                          bool
+	EnableWeb                            bool
 }
 
 // NewConfig instantiates a default new server config
@@ -126,5 +127,6 @@ func NewConfig() *Config {
 		VisitorEmailLimitBurst:               DefaultVisitorEmailLimitBurst,
 		VisitorEmailLimitReplenish:           DefaultVisitorEmailLimitReplenish,
 		BehindProxy:                          false,
+		EnableWeb:                            true,
 	}
 }
diff --git a/server/server.go b/server/server.go
index 4b40db45b6870adba4601467f5bb824ba5574cf7..4ba21bb870dde3d9c2a516ef8c64f004808d114f 100644
--- a/server/server.go
+++ b/server/server.go
@@ -8,11 +8,6 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/emersion/go-smtp"
-	"github.com/gorilla/websocket"
-	"golang.org/x/sync/errgroup"
-	"heckel.io/ntfy/auth"
-	"heckel.io/ntfy/util"
 	"io"
 	"log"
 	"net"
@@ -28,6 +23,12 @@ import (
 	"sync"
 	"time"
 	"unicode/utf8"
+
+	"github.com/emersion/go-smtp"
+	"github.com/gorilla/websocket"
+	"golang.org/x/sync/errgroup"
+	"heckel.io/ntfy/auth"
+	"heckel.io/ntfy/util"
 )
 
 // Server is the main server, providing the UI and API for ntfy
@@ -262,19 +263,19 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
 }
 
 func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
-	if r.Method == http.MethodGet && r.URL.Path == "/" {
+	if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.EnableWeb {
 		return s.handleHome(w, r)
-	} else if r.Method == http.MethodGet && r.URL.Path == "/example.html" {
+	} else if r.Method == http.MethodGet && r.URL.Path == "/example.html" && s.config.EnableWeb {
 		return s.handleExample(w, r)
 	} else if r.Method == http.MethodHead && r.URL.Path == "/" {
 		return s.handleEmpty(w, r, v)
-	} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
+	} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath && s.config.EnableWeb {
 		return s.handleWebConfig(w, r)
 	} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
 		return s.handleUserStats(w, r, v)
-	} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
+	} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) && s.config.EnableWeb {
 		return s.handleStatic(w, r)
-	} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
+	} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) && s.config.EnableWeb {
 		return s.handleDocs(w, r)
 	} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
 		return s.limitRequests(s.handleFile)(w, r, v)
diff --git a/server/server.yml b/server/server.yml
index 3265c751b22dc305642432f1fa316cc90d37eeeb..92516d6f9e5efcf8fde78c0aac948d6432bb5a97 100644
--- a/server/server.yml
+++ b/server/server.yml
@@ -127,7 +127,8 @@
 # manager-interval: "1m"
 
 # Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
-# web app. If you self-host, you don't want to change this. Can be "app" (default) or "home".
+# web app. If you self-host, you don't want to change this.
+# Can be "app" (default), "home" or "disable" to disable the WebUI.
 #
 # web-root: app
 
diff --git a/server/server_test.go b/server/server_test.go
index 0f84b90ac68fe8d40bcfe374e0c15fa59567fc87..06f3cd2dbcb9b8b26a32dd1fa2f69d1b22a667b7 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -6,9 +6,6 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
-	"github.com/stretchr/testify/require"
-	"heckel.io/ntfy/auth"
-	"heckel.io/ntfy/util"
 	"math/rand"
 	"net/http"
 	"net/http/httptest"
@@ -18,6 +15,10 @@ import (
 	"sync"
 	"testing"
 	"time"
+
+	"github.com/stretchr/testify/require"
+	"heckel.io/ntfy/auth"
+	"heckel.io/ntfy/util"
 )
 
 func TestServer_PublishAndPoll(t *testing.T) {
@@ -162,6 +163,40 @@ func TestServer_StaticSites(t *testing.T) {
 	require.Contains(t, rr.Body.String(), "</html>")
 }
 
+func TestServer_WebEnabled(t *testing.T) {
+	conf := newTestConfig(t)
+	conf.EnableWeb = false
+	s := newTestServer(t, conf)
+
+	rr := request(t, s, "GET", "/", "", nil)
+	require.Equal(t, 404, rr.Code)
+
+	rr = request(t, s, "GET", "/example.html", "", nil)
+	require.Equal(t, 404, rr.Code)
+
+	rr = request(t, s, "GET", "/config.js", "", nil)
+	require.Equal(t, 404, rr.Code)
+
+	rr = request(t, s, "GET", "/static/css/home.css", "", nil)
+	require.Equal(t, 404, rr.Code)
+
+	conf2 := newTestConfig(t)
+	conf2.EnableWeb = true
+	s2 := newTestServer(t, conf2)
+
+	rr = request(t, s2, "GET", "/", "", nil)
+	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s2, "GET", "/example.html", "", nil)
+	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s2, "GET", "/config.js", "", nil)
+	require.Equal(t, 200, rr.Code)
+
+	rr = request(t, s2, "GET", "/static/css/home.css", "", nil)
+	require.Equal(t, 200, rr.Code)
+}
+
 func TestServer_PublishLargeMessage(t *testing.T) {
 	c := newTestConfig(t)
 	c.AttachmentCacheDir = "" // Disable attachments
@@ -1303,7 +1338,7 @@ func firebaseServiceAccountFile(t *testing.T) string {
 		return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
 	} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" {
 		filename := filepath.Join(t.TempDir(), "firebase.json")
-		require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0600))
+		require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0o600))
 		return filename
 	}
 	t.SkipNow()