Install Go with Homebrew, build a small web server, and keep it running automatically at login using a launchd plist — macOS's built-in service manager.
The easiest way on macOS is Homebrew. If you don't have it yet, install it first, then:
$ brew install go
$ go version
go version go1.22.5 darwin/arm64
Alternatively, download the .pkg installer from go.dev/dl and run it — it adds /usr/local/go/bin to your PATH automatically.
Either way, open a new terminal after installing so the updated PATH is in effect.
Create a project directory and initialise a module:
$ mkdir -p ~/go-apps/hello && cd ~/go-apps/hello
$ go mod init hello
The app has four routes:
/ — HTML home page with a live visit counter/style.css — the stylesheet served by the Go binary itself/time — current server time as JSON/echo?msg=hi — echoes a query parameter as plain textIt listens on :8081 (all interfaces) so you can open it directly in your browser at http://localhost:8081 without any reverse proxy.
Paste this into ~/go-apps/hello/main.go:
package main
import (
"fmt"
"log"
"net/http"
"sync/atomic"
"time"
)
const listenAddr = ":8081"
var visitCount uint64
const styleCSS = `body{font-family:system-ui,sans-serif;max-width:640px;margin:3rem auto;padding:0 1rem;color:#222}
h1{color:#06c}
a{color:#06c}
code{background:#f4f4f4;padding:2px 6px;border-radius:3px}
.box{border:1px solid #ddd;border-radius:6px;padding:1rem;margin:1rem 0;background:#fafafa}
`
func homeHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
count := atomic.AddUint64(&visitCount, 1)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Hello from Go</title>
<link rel="stylesheet" href="style.css"></head>
<body><h1>Hello from a Go web app</h1>
<p>Served by a Go binary on macOS.</p>
<div class="box">Visit count (process lifetime): <strong>%d</strong></div>
<ul>
<li><a href="time">/time</a> — current server time as JSON</li>
<li><a href="echo?msg=hi">/echo?msg=hi</a> — echo a query parameter</li>
<li><a href="style.css">/style.css</a> — this page's stylesheet</li>
</ul>
</body></html>`, count)
}
func styleHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
fmt.Fprint(w, styleCSS)
}
func timeHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"now":%q,"unix":%d}`+"\n",
time.Now().UTC().Format(time.RFC3339), time.Now().Unix())
}
func echoHandler(w http.ResponseWriter, r *http.Request) {
msg := r.URL.Query().Get("msg")
if msg == "" {
msg = "(no msg query param)"
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, msg)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", homeHandler)
mux.HandleFunc("/style.css", styleHandler)
mux.HandleFunc("/time", timeHandler)
mux.HandleFunc("/echo", echoHandler)
srv := &http.Server{
Addr: listenAddr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("hello: listening on %s", listenAddr)
log.Fatal(srv.ListenAndServe())
}
The only difference from the Linux version is listenAddr = ":8081" (all interfaces) instead of "127.0.0.1:8081", so you can open http://localhost:8081 directly in Safari or Chrome without a reverse proxy.
$ go build -o hello .
$ ./hello &
2026/05/22 hello: listening on :8081
$ curl -s http://localhost:8081/time
{"now":"2026-05-22T13:00:00Z","unix":1779444000}
$ curl -s http://localhost:8081/echo?msg=works
works
$ kill %1
Or just open http://localhost:8081 in your browser — you should see the HTML home page with the visit counter.
go build produces a single native binary with no dependencies. You can copy it to another Mac with the same architecture and run it there with no installation required.
launchd is macOS's init system — it starts services at login and restarts them if they crash. User-level services live in ~/Library/LaunchAgents/ and don't need sudo.
Create ~/Library/LaunchAgents/com.hello.goapp.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.hello.goapp</string>
<key>ProgramArguments</key>
<array>
<string>/Users/yourname/go-apps/hello/hello</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/yourname/go-apps/hello</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/hello-go.log</string>
<key>StandardErrorPath</key>
<string>/tmp/hello-go.log</string>
</dict>
</plist>
Replace yourname with your actual macOS username (whoami prints it). Then load the service:
# Load and start now
$ launchctl load ~/Library/LaunchAgents/com.hello.goapp.plist
# Confirm it's running
$ launchctl list | grep com.hello
12345 0 com.hello.goapp
# Check the log
$ tail -f /tmp/hello-go.log
2026/05/22 hello: listening on :8081
The process will restart automatically if it crashes, and start again on next login.
Key plist fields explained:
Label — a unique reverse-DNS identifier for the service.RunAtLoad — start the service as soon as the plist is loaded (or at next login).KeepAlive — restart the process if it exits for any reason.WorkingDirectory — the process starts here, so relative paths in your code work as expected.StandardOutPath / StandardErrorPath — where log.Printf output lands. Use tail -f to follow it.launchctl unload ~/Library/LaunchAgents/com.hello.goapp.plistlaunchctl unload ... && go build -o hello . && launchctl load ...If you want to host on port 80 or serve multiple apps on the same machine, add nginx in front:
$ brew install nginx
Edit /opt/homebrew/etc/nginx/nginx.conf (Apple Silicon) or /usr/local/etc/nginx/nginx.conf (Intel) and add inside the http block:
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://127.0.0.1:8081/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
}
}
$ brew services start nginx
$ curl -s http://localhost/time
{"now":"2026-05-22T13:00:00Z","unix":1779444000}
Now change listenAddr back to "127.0.0.1:8081" so the Go process is no longer directly reachable — only nginx can talk to it.
# See all loaded user services
$ launchctl list
# Unload (stop) the service
$ launchctl unload ~/Library/LaunchAgents/com.hello.goapp.plist
# Reload after editing the plist or rebuilding the binary
$ launchctl unload ~/Library/LaunchAgents/com.hello.goapp.plist \
&& go build -o ~/go-apps/hello/hello ~/go-apps/hello \
&& launchctl load ~/Library/LaunchAgents/com.hello.goapp.plist
# Follow live logs
$ tail -f /tmp/hello-go.log
# Check what's using port 8081
$ lsof -i :8081