Install Go, write a small web app, run it as a systemd service, and expose it through nginx — all on a real Ubuntu VPS.
Ubuntu's package manager ships a recent version of Go — no need to download a tarball manually:
$ sudo apt-get install -y golang-go
$ go version
go version go1.22.2 linux/amd64
That's all. The go compiler, linker, and standard library are now on PATH.
Create a directory for the project and initialise a module:
$ mkdir -p ~/go-apps/hello && cd ~/go-apps/hello
$ go mod init bozcode.com/hello
The app has four routes:
/ — an HTML home page with a live visit counter/style.css — the stylesheet served by Go itself (no inline CSS — avoids CSP issues)/time — current server time as JSON/echo?msg=hi — echoes a query parameter as plain textThe visit counter is a uint64 incremented with sync/atomic so it is safe under concurrent requests without a mutex.
Paste this into ~/go-apps/hello/main.go:
package main
import (
"fmt"
"log"
"net/http"
"sync/atomic"
"time"
)
const listenAddr = "127.0.0.1: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>This page is served by a Go binary running behind nginx on bozcode.com.</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>
<p><a href="/examples/go/web-hosting/">← Back to the tutorial</a></p>
</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())
}
$ go build -o hello .
$ ./hello &
2026/05/22 hello: listening on 127.0.0.1:8081
$ curl -s http://127.0.0.1:8081/time
{"now":"2026-05-22T13:28:44Z","unix":1779456524}
$ curl -s http://127.0.0.1:8081/echo?msg=works
works
$ kill %1 # stop the background process
go build produces a single statically-linked binary. No runtime, no dependencies to install on the server — just copy the file and run it.
We bind to 127.0.0.1:8081 rather than 0.0.0.0 so the app is not reachable directly from the internet — only nginx can reach it.
Create /etc/systemd/system/goapp-hello.service:
[Unit]
Description=Go web app: hello (demo behind nginx)
After=network.target
[Service]
User=bozcode
Group=bozcode
WorkingDirectory=/home/gordon/go-apps/hello
ExecStart=/home/gordon/go-apps/hello/hello
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now goapp-hello
$ sudo systemctl status goapp-hello
● goapp-hello.service - Go web app: hello (demo behind nginx)
Active: active (running)
Key points about the unit file:
User=bozcode — the service runs as the same unprivileged user as the Django app. Never run a web app as root.Restart=on-failure — systemd will restart the binary automatically if it crashes.StandardOutput=journal — all log.Printf output lands in journald. Use journalctl -u goapp-hello -f to tail it.WorkingDirectory — the process starts in this directory, so relative file paths (if you add a data/ folder later) work as expected.To view logs:
$ sudo journalctl -u goapp-hello -f
To rebuild and restart after editing the code:
$ cd ~/go-apps/hello
$ go build -o hello . && sudo systemctl restart goapp-hello
Add a location block to /etc/nginx/sites-available/bozcode that proxies requests at /goapp/hello/ to the Go process on port 8081:
location ^~ /goapp/hello/ {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8081/;
}
$ sudo nginx -t && sudo systemctl reload nginx
The trailing slash on proxy_pass http://127.0.0.1:8081/; strips the /goapp/hello prefix before forwarding, so the Go app sees /, /time, /echo — not /goapp/hello/time.
^~ gives this location block priority over regex locations so it wins without needing to be ordered carefully in the config file.
The app running on this server is live at /goapp/hello/. Every page load increments the visit counter (stored in-process memory — it resets if the service restarts).
uint64 incremented with sync/atomic.AddUint64. This avoids a mutex entirely — atomic operations are lock-free and safe for a single counter under heavy concurrent load.