Go compiles an application into a single binary with no runtime to install on the server, which makes deployment mostly a matter of copying one file and supervising it. In this tutorial you will install the Go toolchain on Ubuntu 24.04, build a small HTTP service that shuts down gracefully, run it as a hardened systemd service under its own user, and publish it through Nginx with a free Let's Encrypt certificate.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS.
- A non-root user with
sudoprivileges. - A domain name with a DNS
Arecord pointing to your server's public IP. This guide usesapp.your_domainas the placeholder; replace it with your own hostname. - Ports 80 and 443 reachable from the Internet.
If you prefer to compile on your own computer and only copy the binary to the server, you can skip Step 1 and use the cross-compilation command shown in Step 3.
Step 1 - Installing Go
The golang package in the Ubuntu archive lags behind upstream, so install the official release from go.dev. The following command asks go.dev for the current stable version and stores it in a variable:
GO_VERSION=$(curl -fsSL 'https://go.dev/VERSION?m=text' | head -n 1)
echo "$GO_VERSION"
go1.26.1
Download the archive for your architecture (amd64 for Intel and AMD servers, arm64 for ARM) and unpack it into /usr/local/go, removing any previous installation first:
curl -fsSLO "https://go.dev/dl/${GO_VERSION}.linux-amd64.tar.gz"
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf "${GO_VERSION}.linux-amd64.tar.gz"
Add the Go binaries to your PATH for all login shells, then load the change in the current session:
echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee /etc/profile.d/go.sh
source /etc/profile.d/go.sh
Verify the installation:
go version
go version go1.26.1 linux/amd64
Step 2 - Writing a service with graceful shutdown
When systemd stops or restarts a service it sends SIGTERM. A production Go server should catch that signal, stop accepting new connections and let in-flight requests finish, instead of dropping them. Create a project directory and a Go module:
mkdir -p ~/hello && cd ~/hello
go mod init example.com/hello
Create the main file:
nano main.go
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = "127.0.0.1:8080"
}
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":"Hello from Go"}`))
})
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// ctx is cancelled when the process receives SIGINT or SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
log.Printf("listening on %s", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
<-ctx.Done()
log.Println("shutting down, waiting for open requests")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("forced shutdown: %v", err)
}
log.Println("server stopped")
}
The server reads its listen address from the LISTEN_ADDR environment variable and defaults to the loopback interface, so it is never exposed directly to the Internet. The timeouts protect it against slow clients that keep connections open. The method-aware patterns such as "GET /health" require Go 1.22 or newer.
Run it to check that it works:
go run .
2026/09/25 10:40:12 listening on 127.0.0.1:8080
In a second SSH session, send a request:
curl http://127.0.0.1:8080/health
{"status":"ok"}
Stop the program with CTRL+C. It logs shutting down and server stopped, which confirms the signal handling works.
Step 3 - Building a production binary
Build a statically linked binary. CGO_ENABLED=0 removes the dependency on the system C library, -trimpath removes local file paths from the binary, and -ldflags="-s -w" strips debug symbols to reduce its size:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o hello .
Check the result:
file hello
hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
To build on your own computer (Linux, macOS or Windows) for this server instead, set the target operating system and architecture, then copy the file with scp:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o hello .
scp hello your_user@your_server_ip:~/hello/
Use GOARCH=arm64 if the server runs on ARM. Check the server's architecture with uname -m (x86_64 means amd64, aarch64 means arm64).
Step 4 - Installing the binary and configuration
Create a dedicated system user without a login shell or home directory for the service:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin hello
Install the binary to /usr/local/bin, owned by root so the service user cannot modify it:
sudo install -m 0755 ~/hello/hello /usr/local/bin/hello
Create an environment file for configuration and secrets, readable only by root. systemd reads it as root and passes the values to the process:
sudo mkdir -p /etc/hello
sudo nano /etc/hello/hello.env
LISTEN_ADDR=127.0.0.1:8080
DATABASE_URL=postgres://hello:your_strong_password@localhost/hello
Replace the example values with your own, then restrict the file:
sudo chmod 600 /etc/hello/hello.env
Step 5 - Running the application with systemd
Create a unit file that starts the binary at boot, restarts it on failure and applies sandboxing:
sudo nano /etc/systemd/system/hello.service
[Unit]
Description=Hello Go service
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=hello
Group=hello
EnvironmentFile=/etc/hello/hello.env
ExecStart=/usr/local/bin/hello
Restart=on-failure
RestartSec=5
# Must be longer than the shutdown timeout in main.go (20s)
TimeoutStopSec=30
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
PrivateDevices=true
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
ProtectSystem=strict makes the whole filesystem read-only for the service. If your application writes data, add StateDirectory=hello, and systemd creates /var/lib/hello owned by the service user. LimitNOFILE raises the open file limit, which a server handling many concurrent connections needs.
Load and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now hello.service
sudo systemctl status hello.service
● hello.service - Hello Go service
Loaded: loaded (/etc/systemd/system/hello.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-09-25 10:52:31 UTC; 3s ago
Main PID: 4410 (hello)
Test the graceful shutdown path by restarting the service and reading its log:
sudo systemctl restart hello.service
sudo journalctl -u hello.service -n 5 --no-pager
Sep 25 10:53:02 server hello[4410]: 2026/09/25 10:53:02 shutting down, waiting for open requests
Sep 25 10:53:02 server hello[4410]: 2026/09/25 10:53:02 server stopped
Sep 25 10:53:02 server systemd[1]: Stopped hello.service - Hello Go service.
Sep 25 10:53:02 server systemd[1]: Started hello.service - Hello Go service.
Sep 25 10:53:02 server hello[4431]: 2026/09/25 10:53:02 listening on 127.0.0.1:8080
Step 6 - Configuring Nginx as a reverse proxy
Nginx handles TLS and forwards requests to the Go service on the loopback interface. Install it:
sudo apt update
sudo apt install -y nginx
Create a server block for your domain:
sudo nano /etc/nginx/sites-available/hello
upstream hello_backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name app.your_domain;
location / {
proxy_pass http://hello_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $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;
}
}
The upstream block with keepalive, together with proxy_http_version 1.1 and an empty Connection header, lets Nginx reuse connections to the Go server instead of opening a new one per request. Enable the site and reload Nginx:
sudo ln -s /etc/nginx/sites-available/hello /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Open the firewall for SSH and web traffic:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
From your own computer, test the site:
curl http://app.your_domain/
{"message":"Hello from Go"}
Step 7 - Enabling HTTPS with Let's Encrypt
Install Certbot with its Nginx plugin and request a certificate:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.your_domain
Certbot adds the certificate to the server block and configures a redirect from HTTP to HTTPS. Verify it:
curl -I https://app.your_domain/health
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: application/json
The certbot.timer unit renews certificates automatically. Confirm renewal works with sudo certbot renew --dry-run.
Step 8 - Deploying new versions
Because the service runs a single file, an update is a build, an install and a restart. Keep the previous binary so you can roll back quickly:
cd ~/hello
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o hello .
sudo cp /usr/local/bin/hello /usr/local/bin/hello.prev
sudo install -m 0755 hello /usr/local/bin/hello
sudo systemctl restart hello.service
install writes a new file rather than modifying the running one, so it does not fail with Text file busy. Thanks to the graceful shutdown, requests in progress during the restart complete normally; new requests arriving in the fraction of a second while the process restarts get a 502 from Nginx. Check that the new version is healthy:
curl -fsS http://127.0.0.1:8080/health
If something is wrong, roll back with sudo install -m 0755 /usr/local/bin/hello.prev /usr/local/bin/hello followed by sudo systemctl restart hello.service.
Troubleshooting
The service fails with bind: address already in use. Another process holds the port. Find it with sudo ss -tlnp | grep 8080, then stop it or change LISTEN_ADDR in /etc/hello/hello.env.
The service exits immediately. Read the error with sudo journalctl -u hello.service -n 50. To see startup problems interactively, run the binary as the service user with the same environment: sudo systemd-run --pty --uid=hello -p EnvironmentFile=/etc/hello/hello.env /usr/local/bin/hello.
exec format error when starting the binary. The binary was built for a different architecture. Compare file /usr/local/bin/hello with uname -m and rebuild with the right GOARCH.
Nginx returns 502 Bad Gateway. The Go service is not running or listens on a different address than the upstream block. Check sudo systemctl status hello.service and /var/log/nginx/error.log.
go: command not found after installing. The PATH change applies to new login shells. Run source /etc/profile.d/go.sh or log out and back in.
Conclusion
Your Go service now runs as a static binary under a dedicated, sandboxed systemd service, shuts down without dropping in-flight requests and is served over HTTPS by Nginx. Updating it is a matter of replacing one file and restarting the unit.
As next steps, you can automate the build and copy steps in a CI pipeline, package the binary in a minimal container image with a multi-stage Docker build, and expose Prometheus metrics from the application with the prometheus/client_golang library on a port that is not published through Nginx.
