diff --git a/.goreleaser.yml b/.goreleaser.yml
index 42b5554ccba07d2b18f7ee3c6444c74ce9796eee..310e8b9cdd1a24521fa92ab1addcb04a0a15e96a 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -66,6 +66,18 @@ builds:
     hooks:
       post:
         - upx "{{ .Path }}" # apt install upx
+  -
+    id: ntfy_darwin_amd64
+    binary: ntfy
+    env:
+      - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
+    ldflags:
+      - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
+    goos: [darwin]
+    goarch: [amd64]
+    hooks:
+      post:
+        - upx "{{ .Path }}" # apt install upx
 nfpms:
   -
     package_name: ntfy
@@ -131,6 +143,17 @@ archives:
       - client/client.yml
     replacements:
       amd64: x86_64
+  -
+    id: ntfy_darwin
+    builds:
+      - ntfy_darwin_amd64
+    wrap_in_directory: true
+    files:
+      - LICENSE
+      - README.md
+      - client/client.yml
+    replacements:
+      darwin: macOS
 checksum:
   name_template: 'checksums.txt'
 snapshot:
diff --git a/Makefile b/Makefile
index 375b6523df345be6b781e2018b6580922677d2a9..0a3c1a4820dd9ee57c3789653f9a736decc05e2e 100644
--- a/Makefile
+++ b/Makefile
@@ -22,6 +22,7 @@ help:
 	@echo "  make cli-linux-armv7         - Build server & client (Linux, armv7 only)"
 	@echo "  make cli-linux-arm64         - Build server & client (Linux, arm64 only)"
 	@echo "  make cli-windows-amd64       - Build client (Windows, amd64 only)"
+	@echo "  make cli-darwin-amd64        - Build client (macOS, amd64 only)"
 	@echo
 	@echo "Build web app:"
 	@echo "  make web                     - Build the web app"
@@ -120,6 +121,9 @@ cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
 cli-windows-amd64: cli-deps-static-sites
 	goreleaser build --snapshot --rm-dist --debug --id ntfy_windows_amd64
 
+cli-darwin-amd64: cli-deps-static-sites
+	goreleaser build --snapshot --rm-dist --debug --id ntfy_darwin_amd64
+
 cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
 
 cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64
diff --git a/cmd/publish.go b/cmd/publish.go
index 728185e0bae39042936460b17ec79ce8c25f901a..06f8d962c43b612f40e51a15c24a5ca5f35d2d1c 100644
--- a/cmd/publish.go
+++ b/cmd/publish.go
@@ -63,7 +63,7 @@ Examples:
 Please also check out the docs on publishing messages. Especially for the --tags and --delay options, 
 it has incredibly useful information: https://ntfy.sh/docs/publish/.
 
-` + defaultClientConfigFileDescriptionSuffix,
+` + clientCommandDescriptionSuffix,
 }
 
 func execPublish(c *cli.Context) error {
diff --git a/cmd/subscribe.go b/cmd/subscribe.go
index 035f4abeb8ab5df3d603e242d02174234d32d13a..43b9e10e15727af7d4377694b509cdd88a82ca3d 100644
--- a/cmd/subscribe.go
+++ b/cmd/subscribe.go
@@ -6,7 +6,11 @@ import (
 	"github.com/urfave/cli/v2"
 	"heckel.io/ntfy/client"
 	"heckel.io/ntfy/util"
+	"log"
 	"os"
+	"os/exec"
+	"os/user"
+	"path/filepath"
 	"strings"
 )
 
@@ -14,6 +18,12 @@ func init() {
 	commands = append(commands, cmdSubscribe)
 }
 
+const (
+	clientRootConfigFileUnixAbsolute    = "/etc/ntfy/client.yml"
+	clientUserConfigFileUnixRelative    = "ntfy/client.yml"
+	clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
+)
+
 var cmdSubscribe = &cli.Command{
 	Name:      "subscribe",
 	Aliases:   []string{"sub"},
@@ -71,7 +81,7 @@ ntfy subscribe --from-config
     ntfy sub --from-config                           # Read topics from config file
     ntfy sub --config=myclient.yml --from-config     # Read topics from alternate config file
 
-` + defaultClientConfigFileDescriptionSuffix,
+` + clientCommandDescriptionSuffix,
 }
 
 func execSubscribe(c *cli.Context) error {
@@ -195,6 +205,24 @@ func runCommand(c *cli.Context, command string, m *client.Message) {
 	}
 }
 
+func runCommandInternal(c *cli.Context, script string, m *client.Message) error {
+	scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt)
+	if err := os.WriteFile(scriptFile, []byte(scriptHeader+script), 0700); err != nil {
+		return err
+	}
+	defer os.Remove(scriptFile)
+	verbose := c.Bool("verbose")
+	if verbose {
+		log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), script, m.Raw)
+	}
+	cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...)
+	cmd.Stdin = c.App.Reader
+	cmd.Stdout = c.App.Writer
+	cmd.Stderr = c.App.ErrWriter
+	cmd.Env = envVars(m)
+	return cmd.Run()
+}
+
 func envVars(m *client.Message) []string {
 	env := os.Environ()
 	env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
@@ -227,3 +255,18 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
 	}
 	return client.NewConfig(), nil
 }
+
+func defaultConfigFileUnix() string {
+	u, _ := user.Current()
+	configFile := clientRootConfigFileUnixAbsolute
+	if u.Uid != "0" {
+		homeDir, _ := os.UserConfigDir()
+		return filepath.Join(homeDir, clientUserConfigFileUnixRelative)
+	}
+	return configFile
+}
+
+func defaultConfigFileWindows() string {
+	homeDir, _ := os.UserConfigDir()
+	return filepath.Join(homeDir, clientUserConfigFileWindowsRelative)
+}
diff --git a/cmd/subscribe_darwin.go b/cmd/subscribe_darwin.go
new file mode 100644
index 0000000000000000000000000000000000000000..6288c45ee1814ae8d3f0bdc545b1c7411462550e
--- /dev/null
+++ b/cmd/subscribe_darwin.go
@@ -0,0 +1,16 @@
+package cmd
+
+const (
+	scriptExt                      = "sh"
+	scriptHeader                   = "#!/bin/sh\n"
+	clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
+or "~/Library/Application Support" for all other users.`
+)
+
+var (
+	scriptLauncher = []string{"sh", "-c"}
+)
+
+func defaultConfigFile() string {
+	return defaultConfigFileUnix()
+}
diff --git a/cmd/subscribe_linux.go b/cmd/subscribe_linux.go
index 2b55f8062fdf2e8d9243efe61dd26ca77ba9581e..c57660e8242328242bf2eaf9932ca423f91acf77 100644
--- a/cmd/subscribe_linux.go
+++ b/cmd/subscribe_linux.go
@@ -1,57 +1,16 @@
 package cmd
 
-import (
-	"fmt"
-	"github.com/urfave/cli/v2"
-	"heckel.io/ntfy/client"
-	"heckel.io/ntfy/util"
-	"log"
-	"os"
-	"os/exec"
-	"os/user"
-	"path/filepath"
-)
-
 const (
-	defaultClientRootConfigFile              = "/etc/ntfy/client.yml"
-	defaultClientUserConfigFileRelative      = "ntfy/client.yml"
-	defaultClientConfigFileDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
+	scriptExt                      = "sh"
+	scriptHeader                   = "#!/bin/sh\n"
+	clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
 or ~/.config/ntfy/client.yml for all other users.`
 )
 
-func runCommandInternal(c *cli.Context, command string, m *client.Message) error {
-	scriptFile, err := createTmpScript(command)
-	if err != nil {
-		return err
-	}
-	defer os.Remove(scriptFile)
-	verbose := c.Bool("verbose")
-	if verbose {
-		log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
-	}
-	cmd := exec.Command("sh", "-c", scriptFile)
-	cmd.Stdin = c.App.Reader
-	cmd.Stdout = c.App.Writer
-	cmd.Stderr = c.App.ErrWriter
-	cmd.Env = envVars(m)
-	return cmd.Run()
-}
-
-func createTmpScript(command string) (string, error) {
-	scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.sh.tmp", os.TempDir(), util.RandomString(10))
-	script := fmt.Sprintf("#!/bin/sh\n%s", command)
-	if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
-		return "", err
-	}
-	return scriptFile, nil
-}
+var (
+	scriptLauncher = []string{"sh", "-c"}
+)
 
 func defaultConfigFile() string {
-	u, _ := user.Current()
-	configFile := defaultClientRootConfigFile
-	if u.Uid != "0" {
-		homeDir, _ := os.UserConfigDir()
-		return filepath.Join(homeDir, defaultClientUserConfigFileRelative)
-	}
-	return configFile
+	return defaultConfigFileUnix()
 }
diff --git a/cmd/subscribe_windows.go b/cmd/subscribe_windows.go
index cd441baeb1116cb361395a32ed434370a128f9db..1d5c66550e2e0bc58970b8aafd5f35138e8d7003 100644
--- a/cmd/subscribe_windows.go
+++ b/cmd/subscribe_windows.go
@@ -1,48 +1,15 @@
 package cmd
 
-import (
-	"fmt"
-	"github.com/urfave/cli/v2"
-	"heckel.io/ntfy/client"
-	"heckel.io/ntfy/util"
-	"log"
-	"os"
-	"os/exec"
-	"path/filepath"
-)
-
 const (
-	defaultClientUserConfigFileRelative      = "ntfy\\client.yml"
-	defaultClientConfigFileDescriptionSuffix = `The default config file for all client commands is %AppData%\ntfy\client.yml.`
+	scriptExt                      = "bat"
+	scriptHeader                   = ""
+	clientCommandDescriptionSuffix = `The default config file for all client commands is %AppData%\ntfy\client.yml.`
 )
 
-func runCommandInternal(c *cli.Context, command string, m *client.Message) error {
-	scriptFile, err := createTmpScript(command)
-	if err != nil {
-		return err
-	}
-	defer os.Remove(scriptFile)
-	verbose := c.Bool("verbose")
-	if verbose {
-		log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
-	}
-	cmd := exec.Command("cmd.exe", "/Q", "/C", scriptFile)
-	cmd.Stdin = c.App.Reader
-	cmd.Stdout = c.App.Writer
-	cmd.Stderr = c.App.ErrWriter
-	cmd.Env = envVars(m)
-	return cmd.Run()
-}
-
-func createTmpScript(command string) (string, error) {
-	scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.bat", os.TempDir(), util.RandomString(10))
-	if err := os.WriteFile(scriptFile, []byte(command), 0700); err != nil {
-		return "", err
-	}
-	return scriptFile, nil
-}
+var (
+	scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
+)
 
 func defaultConfigFile() string {
-	homeDir, _ := os.UserConfigDir()
-	return filepath.Join(homeDir, defaultClientUserConfigFileRelative)
+	return defaultConfigFileWindows()
 }