Back to Go Examples

Hosting: Run a Go Web App on This Server

Install Go, write a small web app, run it as a systemd service, and expose it through nginx — all on a real Ubuntu VPS.

Contents

  1. Install Go
  2. Write the Web App
  3. The Complete Code
  4. Build & Smoke-Test
  5. Run It as a systemd Service
  6. Expose It through nginx
  7. Live Demo

1. Install Go

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.

2. Write the Web App

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:

The visit counter is a uint64 incremented with sync/atomic so it is safe under concurrent requests without a mutex.

3. The Complete Code

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> &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>
<p><a href="/examples/go/web-hosting/">&larr; 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())
}

4. Build & Smoke-Test

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

5. Run It as a systemd Service

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:

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

6. Expose It through nginx

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.

7. Live Demo

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

The visit counter is a 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.