Back to Go Examples

Hosting: Run a Go Web App on macOS

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.

Contents

  1. Install Go
  2. Write the Web App
  3. The Complete Code
  4. Build & Smoke-Test
  5. Keep It Running with launchd
  6. Optional: Reverse Proxy with nginx
  7. Useful Commands

1. Install Go

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.

2. Write the Web App

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:

It listens on :8081 (all interfaces) so you can open it directly in your browser at http://localhost:8081 without any reverse proxy.

3. The Complete Code

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> &mdash; current server time as JSON</li>
  <li><a href="echo?msg=hi">/echo?msg=hi</a> &mdash; echo a query parameter</li>
  <li><a href="style.css">/style.css</a> &mdash; 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.

4. Build & Smoke-Test

$ 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.

5. Keep It Running with launchd

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:

To stop the service: launchctl unload ~/Library/LaunchAgents/com.hello.goapp.plist
To restart after rebuilding: launchctl unload ... && go build -o hello . && launchctl load ...

6. Optional: Reverse Proxy with nginx

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.

Port 80 on macOS requires a privileged port — nginx opened it as root before dropping privileges. Your Go binary never touches port 80 and doesn't need elevated permissions.

7. Useful Commands

# 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