How to Build and Deploy a Go Web Application on RHEL 7

Go’s standard library includes a production-capable HTTP server in the net/http package. Unlike Java or Python, you do not need an external application server or framework to serve web traffic — the Go runtime handles it natively with impressive performance. This guide covers writing a basic Go web server, compiling it to a self-contained binary, running it as a systemd service on RHEL 7, placing Nginx in front as a reverse proxy, cross-compiling for different architectures, embedding static assets into the binary, and implementing graceful shutdown so in-flight requests complete before the process exits.

Prerequisites

  • RHEL 7 with root or sudo access
  • Go 1.22 or later installed (see the Go installation guide)
  • Nginx available via yum
  • Basic familiarity with Go syntax and systemd

Step 1: Write a Basic net/http Web Server

Create a project directory and initialise a Go module:

mkdir -p ~/projects/webdemo
cd ~/projects/webdemo
go mod init github.com/yourusername/webdemo

Create the main application file with a JSON API endpoint and an HTML homepage. This example demonstrates routing, JSON responses, and structured logging using only the standard library:

cat > ~/projects/webdemo/main.go <<'EOF'
package main

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

type StatusResponse struct {
    Status  string `json:"status"`
    Message string `json:"message"`
    Version string `json:"version"`
}

func statusHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    resp := StatusResponse{
        Status:  "ok",
        Message: "Web demo is running",
        Version: "1.0.0",
    }
    if err := json.NewEncoder(w).Encode(resp); err != nil {
        http.Error(w, "encoding error", http.StatusInternalServerError)
        return
    }
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" {
        http.NotFound(w, r)
        return
    }
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.Write([]byte(`<!DOCTYPE html><html>
<head><title>Go Web Demo</title></head>
<body><h1>Go Web Demo</h1><p>Running on RHEL 7</p></body>
</html>`))
}

func main() {
    logger := log.New(os.Stdout, "[webdemo] ", log.LstdFlags)

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/", homeHandler)
    mux.HandleFunc("/api/status", statusHandler)

    srv := &http.Server{
        Addr:         ":" + port,
        Handler:      mux,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 30 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // Start server in a goroutine
    go func() {
        logger.Printf("Listening on port %s", port)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            logger.Fatalf("ListenAndServe: %v", err)
        }
    }()

    // Graceful shutdown on SIGTERM or SIGINT
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
    <-quit

    logger.Println("Shutting down server...")
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        logger.Fatalf("Forced shutdown: %v", err)
    }
    logger.Println("Server stopped cleanly.")
}
EOF

Test the server locally:

go run main.go &
curl http://localhost:8080/
curl http://localhost:8080/api/status
kill %1

Step 2: Build the Production Binary

Compile the application with optimised flags. The -ldflags="-s -w" flag strips the symbol table and DWARF debug information, significantly reducing binary size:

go build -ldflags="-s -w" -o webdemo main.go
ls -lh webdemo

Verify the binary runs correctly:

./webdemo &
curl -s http://localhost:8080/api/status | python -m json.tool
kill %1

Step 3: Cross-Compile for a Different Architecture

One of Go’s most powerful features is effortless cross-compilation. You can build a Linux ARM64 binary from an x86-64 RHEL 7 machine — useful for deploying to ARM servers or Raspberry Pi systems — by setting two environment variables:

# Build for Linux ARM64
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o webdemo-arm64 main.go

# Build for Linux 386 (32-bit)
GOOS=linux GOARCH=386 go build -ldflags="-s -w" -o webdemo-386 main.go

# Build for Windows AMD64 (cross-platform)
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o webdemo.exe main.go

# Verify the target architecture
file webdemo webdemo-arm64

No special toolchain is required — Go’s compiler handles all of this natively.

Step 4: Embed Static Assets with go:embed

Go 1.16 introduced the //go:embed directive, which bundles static files (HTML templates, CSS, images, etc.) directly into the compiled binary at build time. This eliminates the need to distribute static assets separately.

Create a static assets directory:

mkdir -p ~/projects/webdemo/static
cat > ~/projects/webdemo/static/style.css <<'EOF'
body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; }
h1 { color: #333; }
EOF

Update main.go to embed and serve the directory. Add the following to your imports and main function:

import (
    "embed"
    "io/fs"
    // ... other imports
)

//go:embed static
var staticFiles embed.FS

// In main(), add to the mux:
staticFS, _ := fs.Sub(staticFiles, "static")
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))

Rebuild. The compiled binary now contains all files from the static/ directory and serves them from memory — no filesystem access required at runtime.

Step 5: Deploy the Binary and Create a System User

Deploy the application binary to a standard location and run it as a dedicated non-privileged user:

sudo useradd -r -s /sbin/nologin goapp
sudo mkdir -p /opt/webdemo
sudo cp ~/projects/webdemo/webdemo /opt/webdemo/webdemo
sudo chown goapp:goapp /opt/webdemo/webdemo
sudo chmod 750 /opt/webdemo/webdemo

# Create log directory
sudo mkdir -p /var/log/webdemo
sudo chown goapp:goapp /var/log/webdemo

Step 6: Create the systemd Service Unit

Write a systemd service unit that starts the Go application on boot, restarts it on unexpected exit, and passes configuration through environment variables:

sudo tee /etc/systemd/system/webdemo.service <<'EOF'
[Unit]
Description=Go Web Demo Application
Documentation=https://example.com
After=network.target

[Service]
Type=simple
User=goapp
Group=goapp

WorkingDirectory=/opt/webdemo

Environment=PORT=8080
Environment=GIN_MODE=release

ExecStart=/opt/webdemo/webdemo

# Go handles SIGTERM gracefully; exit 0 and 143 are both clean
SuccessExitStatus=0 143

Restart=on-failure
RestartSec=5s
StartLimitInterval=60s
StartLimitBurst=3

# Security hardening
NoNewPrivileges=yes
PrivateTmp=yes

StandardOutput=journal
StandardError=journal
SyslogIdentifier=webdemo

[Install]
WantedBy=multi-user.target
EOF

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable webdemo
sudo systemctl start webdemo
sudo systemctl status webdemo

Monitor the logs:

sudo journalctl -u webdemo -f

Step 7: Configure Nginx as a Reverse Proxy

Install Nginx and configure it to proxy requests to the Go application. This setup handles load balancing, SSL termination, and serves as the public-facing entry point:

sudo yum install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx

Create the Nginx server block:

sudo tee /etc/nginx/conf.d/webdemo.conf <<'EOF'
upstream go_backend {
    server 127.0.0.1:8080;
    keepalive 64;
}

server {
    listen 80;
    server_name webdemo.example.com;

    access_log /var/log/nginx/webdemo.access.log combined;
    error_log  /var/log/nginx/webdemo.error.log;

    # Increase buffer sizes for Go's response headers
    proxy_buffer_size          128k;
    proxy_buffers              4 256k;
    proxy_busy_buffers_size    256k;

    location / {
        proxy_pass         http://go_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;
        proxy_read_timeout 60s;
        proxy_connect_timeout 10s;
    }

    location /static/ {
        proxy_pass http://go_backend;
        expires 1d;
        add_header Cache-Control "public, immutable";
    }
}
EOF

sudo nginx -t
sudo systemctl reload nginx

Step 8: Graceful Shutdown with Signal Handling

The graceful shutdown code in main.go (Step 1) uses Go’s os/signal package to catch SIGTERM and SIGINT. When systemctl stop webdemo is executed, systemd sends SIGTERM to the process. The application then:

  1. Stops accepting new connections
  2. Waits up to 30 seconds for in-flight requests to complete
  3. Exits with code 0 once all active handlers have returned

You can test this behaviour manually:

# Send a request that takes a few seconds (simulated with a slow endpoint)
curl http://localhost:8080/ &

# Immediately stop the service
sudo systemctl stop webdemo

# The curl request should complete before the process exits
# Check journal to confirm clean shutdown
sudo journalctl -u webdemo --no-pager | tail -5

The final log line should read Server stopped cleanly. — confirming no requests were dropped. This is critical for zero-downtime deployments where you roll over to a new binary while traffic is live.

Conclusion

Building and deploying a Go web application on RHEL 7 demonstrates several of the language’s production strengths: a self-contained binary with no runtime dependencies, built-in HTTP handling in the standard library, trivially easy cross-compilation for different targets, and clean signal-based graceful shutdown. By running the binary as a systemd service under a non-privileged account and placing Nginx in front, you achieve a secure, maintainable, and highly performant deployment architecture suitable for production workloads. The entire deployment artifact is a single binary file, making updates as simple as copying the new binary, restarting the service, and confirming the health check passes.