Deploy a Go Service Monitor to EC2
Build and deploy a Go REST API that monitors service health on AWS EC2.
Introduction
30 Second Summary
Think about the services you use every day. Email, chat apps, cloud storage. Someone has to make sure those services are actually running, and when they go down, someone needs to know immediately.
In this project, you will build a Service Monitor API in Go that checks whether web services are healthy. You will then deploy it to a real Linux server on AWS EC2 so it runs as a production service accessible from anywhere.
What You'll Build
A REST API built entirely with Go's standard library that lets you register services to monitor, check their health concurrently using goroutines, and log every request as structured JSON.
By the end of this project, you'll have:
- A Go REST API with endpoints for registering services, checking their health individually, and checking all services concurrently.
- Structured JSON logging and graceful shutdown handling that production Go services require.
- A live EC2 instance running your API as a systemd service behind an Nginx reverse proxy, accessible from your browser.
- Secret Mission: Add a background worker that automatically checks all services on a schedule and exposes uptime statistics through a report endpoint.
Are there any prerequisites?
You need basic programming experience and an AWS account. This project assumes you are working on a Windows machine. No prior Go experience is required.
Install Go on Windows
Go is a compiled language designed for building fast, reliable server applications. Its standout feature for deployment is that it compiles down to a single binary with no runtime dependencies.
In this step, you'll install Go on your Windows machine and set up your project directory. By the end, you'll have a working Go environment ready to build your service monitor API.
In this step, get ready to:
- Install Go 1.26.2 on your machine.
- Verify Go is working from your terminal.
- Initialize a Go module for your project.
Download and install Go
Go provides a one-click MSI installer for Windows that handles everything, including adding Go to your system PATH.
- Open your browser and navigate to the Go downloads page.
- Download the Windows installer named go1.26.2.windows-amd64.msi.
- Run the downloaded .msi file.
- Accept the default settings and click through each screen of the installer.
What is Go?
Go is a compiled language built by Google for server-side applications. It compiles your code into a single binary file with zero runtime dependencies.
This means deploying to a Linux server is as simple as copying one file. No installing runtimes, no managing package versions on the server.
Verify Go is installed
The installer updated your system PATH, but any terminal window that was already open won't see the change. You need a fresh PowerShell window.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Confirm Go is installed by running this command:
go version
✔️ I see a version number
You should see output like go version go1.26.2 windows/amd64. Go is ready to use.
ⓧ Command not found
If PowerShell says the command is not recognized, try these fixes:
- Close this PowerShell window and open a brand new one. The PATH update only takes effect in new windows.
- If it still fails, check that C:\Program Files\Go\bin exists. If not, re-run the MSI installer.
- As a last resort, restart your computer so Windows picks up the new PATH value.
Create your project directory and initialize a Go module
Every Go project starts with a module. A module tracks your project's name and any external dependencies it uses.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir service-monitor
cd service-monitor
Your PowerShell prompt now shows service-monitor as the current folder.
- Initialize your Go module by running:
go mod init service-monitor
What does this command do?
The go mod init command creates a go.mod file in your project folder. This file declares your module's name (service-monitor) and which Go version you're using.
Think of it like a package.json in Node or a CMakeLists.txt in C++. It's the root of your project's dependency graph.
- Confirm the go.mod file was created by running:
ls
You should see go.mod listed in the output.
Don't see go.mod?
Make sure you ran go mod init service-monitor from inside the service-monitor folder. Check your prompt shows the correct directory.
If you see an error about Go not being found, make sure you opened a new PowerShell window after installing Go.
Help me troubleshoot my go mod init command.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and you're ready to move on.
ⓧ I'd like to double check the full code
Here's what your go.mod file should look like:
module service-monitor
go 1.26.2
Your Go environment is ready and your project is initialized. Next up, you'll write your first Go code and build an HTTP server with structured logging.
Build the HTTP Server with Structured Logging
Every API starts the same way: a server that listens for requests and sends back responses. Your service monitor needs an HTTP server that can handle incoming requests, route them to the right handler, and log everything in a structured format that machines can parse.
In this step, you'll create your first Go source file with a working HTTP server, a health check endpoint, and production-style JSON logging. By the end, you'll be able to hit your API in a browser and see structured log output in your terminal.
In this step, get ready to:
- Create main.go with an HTTP server and a health check endpoint.
- Set up structured JSON logging with log/slog.
- Add a request logging middleware that records method, path, and duration for every request.
Create main.go with the server and health endpoint
Go's standard library includes a full HTTP server in the net/http package. Since Go 1.22, the built-in router supports method matching and path wildcards. You don't need a framework like Gin or Chi for a simple API.
- In your service-monitor folder, create a new file called main.go.
- Copy the following code into main.go to set up the server's main function:
package main
import (
"encoding/json"
"log/slog"
"net/http"
"os"
"time"
)
func main() {
// Set up structured JSON logger.
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
mux := http.NewServeMux()
// GET /health - simple liveness check
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
server := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
logger.Info("server starting", "port", port)
if err := server.ListenAndServe(); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}
What does this code do?
This sets up a complete HTTP server using only Go's standard library.
- slog.New(slog.NewJSONHandler(...)) creates a structured logger that outputs line-delimited JSON instead of plain text. This is what production Go services use for log aggregation.
- http.NewServeMux() creates a request router. The "GET /health" pattern uses Go 1.22+ method matching, so only GET requests reach this handler.
- The health endpoint returns a JSON object with status and time fields. This is a standard liveness probe that load balancers and monitoring tools can ping.
- os.Getenv("PORT") reads the port from an environment variable, falling back to 8080. This is a production pattern that lets deployment platforms configure the port without code changes.
Why standard library over a framework like Gin?
As a C++ developer, you'll appreciate that Go's standard library gives you the same control as writing your own socket handlers, but with the convenience of a high-level API. Since Go 1.22 added method matching and path wildcards to the default router, third-party frameworks are unnecessary for most APIs.
Using only the standard library also means zero external dependencies. Your compiled binary is self-contained, which makes cross-compilation and deployment trivial.
- Save main.go.
- Test that the server starts by running this command in your PowerShell terminal:
go run .
You should see a JSON log line that says "msg":"server starting" with "port":"8080". The terminal will hang because the server is running and waiting for requests.
- Press Ctrl+C to stop the server for now.
Seeing an error instead of the log line?
- If you see cannot find module, make sure your terminal is inside the service-monitor folder (where go.mod lives).
- If you see syntax error, check that all curly braces and parentheses are matched. Go is strict about brace placement.
- If you see imported and not used, remove any import lines that your editor added automatically but that the code does not reference yet.
Why won't my Go server start?
Add the request logging middleware
Right now, your server logs when it starts, but you have no visibility into individual requests. In production, you need to know which endpoints are being hit and how long each request takes.
A middleware function wraps your router and runs code before and after every request. You'll create one that measures response time and logs it as structured JSON.
- In main.go, add the following loggingMiddleware function above the func main() line (but below the imports):
// loggingMiddleware logs every incoming request with method, path, and duration.
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Info("request handled",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
What does this middleware do?
- It takes a logger and the next handler (your mux), then returns a new handler that wraps the original.
- time.Now() captures the start time before the request is processed. After next.ServeHTTP completes, it calculates the duration.
- The structured log entry includes method, path, and duration_ms as key-value pairs. This makes log output searchable and parseable by tools like CloudWatch or Datadog.
Now you need to wire the middleware into your server so every request passes through it.
- In the main() function, find this line inside the server struct:
Handler: mux,
- Replace mux with loggingMiddleware(logger, mux) so the line looks like this:
Handler: loggingMiddleware(logger, mux),
Now every request to your server will be logged with its method, path, and response time before reaching your handlers.
- Save main.go.
- Start the server again by running:
go run .
- Open a second PowerShell window and test the health endpoint by running:
curl http://localhost:8080/health
You should see a JSON response like {"status":"ok","time":"2026-05-05T..."} in your second terminal.
Back in your first terminal (where the server is running), you should see a structured JSON log line with "msg":"request handled", "method":"GET", "path":"/health", and a duration_ms value. That is your middleware in action.
- Press Ctrl+C in the server terminal to stop the server.
Not seeing the JSON response from curl?
- Make sure the server is still running in your first terminal (you should see the server starting log line). If you see an error, fix it and run go run . again.
- If curl says Connection refused, confirm you are using port 8080 in the URL and that no other process is using that port.
- If Windows Firewall pops up asking to allow access, click Allow access. This allows your local Go server to accept connections.
Help me debug my Go server connection issue.
Why structured logging over fmt.Println?
log/slog is Go's standard structured logging package (added in Go 1.21). It outputs machine-parseable JSON that log aggregation tools like CloudWatch, Datadog, or Splunk can index and search automatically.
Plain fmt.Println outputs unstructured text that is impossible to filter at scale. Structured logs let you query things like "show me all requests to /health that took longer than 100ms" without writing custom parsers.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that the server runs cleanly with go run ..
ⓧ I'd like to double check the full code
Here is the complete main.go file as it should look at the end of this step.
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
// loggingMiddleware logs every incoming request with method, path, and duration.
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Info("request handled",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
func main() {
// Set up structured JSON logger.
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
mux := http.NewServeMux()
// GET /health - simple liveness check
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
server := &http.Server{
Addr: ":" + port,
Handler: loggingMiddleware(logger, mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
logger.Info("server starting", "port", port)
if err := server.ListenAndServe(); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}
Unused imports
The full code includes imports like context, fmt, sync, os/signal, and syscall that you will use in later steps. If Go gives you an imported and not used error, remove those lines for now. Your editor's Go extension will add them back automatically when the code references them.
Your HTTP server is running with structured logging and a health check endpoint. Next up, you'll add the service monitoring endpoints that let you register URLs and check whether they are alive.
Add Service Monitoring Endpoints
Your server is running and logging every request. Now it needs something useful to do.
A service monitor needs to track which services exist and whether they're responding. In this step, you'll build the core data structures and three new API endpoints that let you register services and check their health.
In this step, get ready to:
- Define a Service struct and a thread-safe in-memory store.
- Implement endpoints to register and list services.
- Implement an endpoint that checks whether a service URL is reachable.
Define the data model structs
Every service you monitor needs a name and a URL. Every health check produces a result with status, response time, and a timestamp. In Go, you define these shapes as structs.
- In main.go, find the closing parenthesis ) of your import block.
- Add the following two struct definitions below the import block and above the loggingMiddleware function:
// Service represents a monitored service.
type Service struct {
Name string `json:"name"`
URL string `json:"url"`
}
// CheckResult holds the outcome of a health check.
type CheckResult struct {
Name string `json:"name"`
URL string `json:"url"`
Status string `json:"status"`
ResponseTime time.Duration `json:"response_time_ms"`
CheckedAt time.Time `json:"checked_at"`
}
What do these structs do?
Service defines the two pieces of information you need to monitor something: a human-readable name and the URL to check.
CheckResult captures what happened when you checked a service. The json:"..." tags after each field tell Go's JSON encoder what key name to use when converting the struct to JSON output.
- Save main.go.
- You won't see a change yet. The next chunk uses these structs in a store.
Now you need a place to hold the list of registered services. Go's sync.Mutex ensures that only one request modifies the list at a time, preventing data corruption when multiple requests arrive simultaneously.
- Directly below the CheckResult struct you just added, paste the following ServiceStore struct and its three methods:
// ServiceStore manages registered services with thread-safe access.
type ServiceStore struct {
mu sync.Mutex
services []Service
}
func (s *ServiceStore) Add(svc Service) {
s.mu.Lock()
defer s.mu.Unlock()
s.services = append(s.services, svc)
}
func (s *ServiceStore) All() []Service {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]Service, len(s.services))
copy(result, s.services)
return result
}
func (s *ServiceStore) FindByName(name string) (Service, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, svc := range s.services {
if svc.Name == name {
return svc, true
}
}
return Service{}, false
}
What does this code do?
ServiceStore wraps a slice of services with a mutex lock. Every method locks the mutex before reading or writing, then unlocks it when done. This is Go's standard pattern for thread-safe shared state.
- Add appends a new service to the slice.
- All returns a copy of the slice so callers can read it without holding the lock.
- FindByName searches by name and returns the service plus a boolean indicating whether it was found.
- Save main.go.
- Verify the code compiles by running this in your PowerShell terminal:
go build .
If it compiles without errors, you'll see no output. That silence means success.
Seeing a compile error?
- If you see syntax error: unexpected..., check that you placed the structs between the import block's closing ) and the loggingMiddleware function. Do not place them inside another function.
- If you see imported and not used for fmt or other packages, that is expected at this stage. It will resolve once you add the remaining code.
Help me fix a Go compile error in my ServiceStore code
Implement the service registration endpoints
Your store is ready. Now you need HTTP endpoints that let clients add services and list what has been registered.
- In the main() function, find this line:
mux := http.NewServeMux()
- Add the following line directly above it to create the store instance:
store := &ServiceStore{}
- Now find the closing }) of the GET /health handler.
- Add the following two endpoint handlers below it:
// GET /services - list all registered services
mux.HandleFunc("GET /services", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(store.All())
})
// POST /services - register a new service
mux.HandleFunc("POST /services", func(w http.ResponseWriter, r *http.Request) {
var svc Service
if err := json.NewDecoder(r.Body).Decode(&svc); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
if svc.Name == "" || svc.URL == "" {
http.Error(w, `{"error":"name and url are required"}`, http.StatusBadRequest)
return
}
store.Add(svc)
logger.Info("service registered", "name", svc.Name, "url", svc.URL)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(svc)
})
What do these endpoints do?
- GET /services returns the full list of registered services as a JSON array. If none are registered yet, it returns an empty array.
- POST /services reads a JSON body with name and url fields, validates them, stores the new service, and responds with HTTP 201 Created.
Notice how Go 1.22+ routing lets you write "POST /services" directly. The method is part of the pattern, so only POST requests match that handler.
- Save main.go.
- You won't see a change yet. The next substep adds the final endpoint, and then you'll test everything together.
Implement the health check endpoint
The most interesting endpoint is the one that actually checks a service. It makes an HTTP request to the registered URL and reports whether the service is healthy or not.
- In main.go, find the loggingMiddleware function.
- Add the following checkService function directly above loggingMiddleware:
// checkService performs an HTTP GET to the service URL and returns the result.
func checkService(svc Service) CheckResult {
client := &http.Client{Timeout: 5 * time.Second}
start := time.Now()
resp, err := client.Get(svc.URL)
duration := time.Since(start)
result := CheckResult{
Name: svc.Name,
URL: svc.URL,
ResponseTime: duration / time.Millisecond,
CheckedAt: time.Now(),
}
if err != nil {
result.Status = "unhealthy"
return result
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
result.Status = "healthy"
} else {
result.Status = "unhealthy"
}
return result
}
What does this function do?
checkService creates an HTTP client with a 5-second timeout and makes a GET request to the service URL. It measures how long the request takes.
If the request fails entirely (network error, timeout), the status is unhealthy. If it succeeds with a 2xx or 3xx status code, the service is healthy. Anything else (4xx, 5xx) is unhealthy.
- Now scroll down to the main() function and find the closing }) of the POST /services handler you just added.
- Add the following endpoint handler below it:
// GET /services/{name}/check - check a single service
mux.HandleFunc("GET /services/{name}/check", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
svc, found := store.FindByName(name)
if !found {
http.Error(w, `{"error":"service not found"}`, http.StatusNotFound)
return
}
result := checkService(svc)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
})
What is {name} in the route pattern?
Go 1.22+ introduced path wildcards in route patterns. The {name} segment captures whatever the client puts in that position of the URL. You extract it with r.PathValue("name").
So a request to /services/google/check sets name to "google". The handler then looks up that service in the store and checks it.
- Save main.go.
- Test that it compiles by running:
go run .
You should see a JSON log line with "msg":"server starting" in the terminal. The server is now running with all three new endpoints.
- Open a second PowerShell window to test the API. Register a service by running:
curl -X POST http://localhost:8080/services -H "Content-Type: application/json" -d '{"name":"google","url":"https://www.google.com"}'
You should see a JSON response echoing back the service you registered with its name and URL.
- Now check that service's health by running:
curl http://localhost:8080/services/google/check
You should see a JSON response with "status":"healthy" and a response_time_ms value showing how long Google took to respond.
- Press Ctrl+C in the first PowerShell window to stop the server.
Getting an error when you curl?
- If you see service not found, make sure you ran the POST command first to register the service before checking it.
- If you see invalid JSON, check that your POST body uses double quotes around keys and values. Single quotes around the JSON body are fine in PowerShell, but the JSON inside must use double quotes.
- If you see Connection refused, make sure the server is still running in your first PowerShell window.
Help me troubleshoot my Go service monitoring endpoints
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that the server ran without compile errors.
ⓧ I'd like to double check the full code
Here is the complete main.go at the end of this step:
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
// Service represents a monitored service.
type Service struct {
Name string `json:"name"`
URL string `json:"url"`
}
// CheckResult holds the outcome of a health check.
type CheckResult struct {
Name string `json:"name"`
URL string `json:"url"`
Status string `json:"status"`
ResponseTime time.Duration `json:"response_time_ms"`
CheckedAt time.Time `json:"checked_at"`
}
// ServiceStore manages registered services with thread-safe access.
type ServiceStore struct {
mu sync.Mutex
services []Service
}
func (s *ServiceStore) Add(svc Service) {
s.mu.Lock()
defer s.mu.Unlock()
s.services = append(s.services, svc)
}
func (s *ServiceStore) All() []Service {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]Service, len(s.services))
copy(result, s.services)
return result
}
func (s *ServiceStore) FindByName(name string) (Service, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, svc := range s.services {
if svc.Name == name {
return svc, true
}
}
return Service{}, false
}
// checkService performs an HTTP GET to the service URL and returns the result.
func checkService(svc Service) CheckResult {
client := &http.Client{Timeout: 5 * time.Second}
start := time.Now()
resp, err := client.Get(svc.URL)
duration := time.Since(start)
result := CheckResult{
Name: svc.Name,
URL: svc.URL,
ResponseTime: duration / time.Millisecond,
CheckedAt: time.Now(),
}
if err != nil {
result.Status = "unhealthy"
return result
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
result.Status = "healthy"
} else {
result.Status = "unhealthy"
}
return result
}
// loggingMiddleware logs every incoming request with method, path, and duration.
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Info("request handled",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
func main() {
// Set up structured JSON logger.
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
store := &ServiceStore{}
mux := http.NewServeMux()
// GET /health - simple liveness check
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
})
// GET /services - list all registered services
mux.HandleFunc("GET /services", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(store.All())
})
// POST /services - register a new service
mux.HandleFunc("POST /services", func(w http.ResponseWriter, r *http.Request) {
var svc Service
if err := json.NewDecoder(r.Body).Decode(&svc); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
if svc.Name == "" || svc.URL == "" {
http.Error(w, `{"error":"name and url are required"}`, http.StatusBadRequest)
return
}
store.Add(svc)
logger.Info("service registered", "name", svc.Name, "url", svc.URL)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(svc)
})
// GET /services/{name}/check - check a single service
mux.HandleFunc("GET /services/{name}/check", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
svc, found := store.FindByName(name)
if !found {
http.Error(w, `{"error":"service not found"}`, http.StatusNotFound)
return
}
result := checkService(svc)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
server := &http.Server{
Addr: ":" + port,
Handler: loggingMiddleware(logger, mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
logger.Info("server starting", "port", port)
if err := server.ListenAndServe(); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}
Your API can now register services and check their health one at a time. Next up, you'll add the power of concurrency. You'll check all services at once using goroutines and add graceful shutdown so your server handles termination cleanly.
Add Concurrent Health Checks and Graceful Shutdown
Your API can check one service at a time, but production monitors need to check dozens of services simultaneously. If you checked them one by one, a single slow service would block the entire response.
In this step, you'll use goroutines and channels to check all services in parallel. You'll also add graceful shutdown so your server finishes handling in-flight requests before exiting.
In this step, get ready to:
- Implement concurrent health checks with goroutines and channels.
- Add graceful shutdown so in-flight requests complete before exit.
- Test all endpoints locally with curl.
Implement the check-all endpoint with goroutines
Go's concurrency model uses goroutines (lightweight threads) and channels (typed pipes for communication). Each goroutine checks one service, and all results flow back through a single channel.
- In main.go, find the closing }) of your GET /services/{name}/check handler.
- Add the following handler below it:
// GET /check-all - check all services concurrently
mux.HandleFunc("GET /check-all", func(w http.ResponseWriter, r *http.Request) {
services := store.All()
if len(services) == 0 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]CheckResult{})
return
}
results := make(chan CheckResult, len(services))
for _, svc := range services {
go func(s Service) {
results <- checkService(s)
}(svc)
}
var allResults []CheckResult
for range services {
allResults = append(allResults, <-results)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(allResults)
})
What does this code do?
This handler checks every registered service at the same time instead of one by one.
- make(chan CheckResult, len(services)) creates a buffered channel that can hold one result per service.
- go func(s Service) { ... }(svc) launches a goroutine for each service. The go keyword starts a new concurrent function.
- results <- checkService(s) sends each check result into the channel.
- The second for range services loop reads one result per service from the channel, collecting them into a slice.
Why pass svc as a function argument?
The go func(s Service) { ... }(svc) pattern passes svc as an argument to the goroutine instead of capturing it directly. This ensures each goroutine gets its own copy of the service value. Without this, all goroutines would share the same loop variable and might all check the last service.
- Save main.go.
Seeing a compile error?
- Make sure the new handler is placed after the previous handler's closing }) and before the port := os.Getenv("PORT") line.
- Check that []CheckResult{} uses curly braces, not parentheses.
Help me fix my Go compile error in the check-all handler
Add graceful shutdown
Right now, if you stop the server with Ctrl+C, it dies immediately. Any request being handled at that moment gets dropped. Production servers need to finish in-flight requests before exiting.
Go's http.Server.Shutdown method stops accepting new connections while letting active ones finish. You'll combine this with os/signal to listen for termination signals from the operating system.
- In main.go, scroll to the bottom of your main() function.
- Find these lines:
logger.Info("server starting", "port", port)
if err := server.ListenAndServe(); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
- Delete those lines and replace them with the following graceful shutdown code:
// Graceful shutdown: listen for SIGINT or SIGTERM.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
logger.Info("server starting", "port", port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}()
// Block until a signal is received.
sig := <-quit
logger.Info("shutting down", "signal", sig.String())
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("shutdown error", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
What does this code do?
- quit := make(chan os.Signal, 1) creates a channel that will receive OS signals like Ctrl+C or systemd stop commands.
- signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) registers which signals you care about. SIGINT is Ctrl+C. SIGTERM is what Linux sends when stopping a service.
- The server now runs inside a goroutine with go func() { ... }(), freeing the main goroutine to wait for the shutdown signal.
- sig := <-quit blocks the main goroutine until a signal arrives.
- server.Shutdown(ctx) stops accepting new connections and waits up to 15 seconds for active requests to finish.
- Also add this comment above your port := os.Getenv("PORT") line:
// Determine port from environment or default to 8080.
- Save main.go.
Seeing an error about http.ErrServerClosed?
- Make sure the condition reads err != nil && err != http.ErrServerClosed with two ampersands. This prevents the server from logging a false error when shutdown is triggered intentionally.
- Check that server.ListenAndServe() is inside the go func() { ... }() block, not at the top level of main.
Help me fix my graceful shutdown code
Test locally with go run and curl
Time to verify everything works. You'll start the server, register a couple of services, then hit the check-all endpoint to see concurrent health checks in action.
- Start your server by running this command in your service-monitor directory:
go run .
You should see a JSON log line with "msg":"server starting" and "port":"8080".
- Open a second PowerShell window. Register two services by running these commands:
curl -X POST http://localhost:8080/services -H "Content-Type: application/json" -d '{"name":"google","url":"https://www.google.com"}'
curl -X POST http://localhost:8080/services -H "Content-Type: application/json" -d '{"name":"github","url":"https://github.com"}'
- Check all services concurrently by running:
curl http://localhost:8080/check-all
You should see a JSON array with two results. Each object shows the service name, URL, status ("healthy" or "unhealthy"), response time in milliseconds, and the timestamp.
- Test graceful shutdown by pressing Ctrl+C in the terminal running the server.
You should see a log line with "msg":"shutting down" followed by "msg":"server stopped". The server waits for active requests to finish before exiting cleanly.
Server exits immediately without the stopped message?
- Make sure sig := <-quit is in the main goroutine (not inside the go func() block). This line must block the main function from returning.
- Check that signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) appears before the go func() that starts the server.
Help me debug my graceful shutdown
✔️ Awesome, I've got everything!
Double check you've saved main.go and confirmed the check-all endpoint returns results.
ⓧ I'd like to double check the full code
Here is the complete main.go file at this point. Compare it against yours to spot any differences.
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
// Service represents a monitored service.
type Service struct {
Name string `json:"name"`
URL string `json:"url"`
}
// CheckResult holds the outcome of a health check.
type CheckResult struct {
Name string `json:"name"`
URL string `json:"url"`
Status string `json:"status"`
ResponseTime time.Duration `json:"response_time_ms"`
CheckedAt time.Time `json:"checked_at"`
}
// ServiceStore manages registered services with thread-safe access.
type ServiceStore struct {
mu sync.Mutex
services []Service
}
func (s *ServiceStore) Add(svc Service) {
s.mu.Lock()
defer s.mu.Unlock()
s.services = append(s.services, svc)
}
func (s *ServiceStore) All() []Service {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]Service, len(s.services))
copy(result, s.services)
return result
}
func (s *ServiceStore) FindByName(name string) (Service, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, svc := range s.services {
if svc.Name == name {
return svc, true
}
}
return Service{}, false
}
// checkService performs an HTTP GET to the service URL and returns the result.
func checkService(svc Service) CheckResult {
client := &http.Client{Timeout: 5 * time.Second}
start := time.Now()
resp, err := client.Get(svc.URL)
duration := time.Since(start)
result := CheckResult{
Name: svc.Name,
URL: svc.URL,
ResponseTime: duration / time.Millisecond,
CheckedAt: time.Now(),
}
if err != nil {
result.Status = "unhealthy"
return result
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
result.Status = "healthy"
} else {
result.Status = "unhealthy"
}
return result
}
// loggingMiddleware logs every incoming request with method, path, and duration.
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Info("request handled",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
func main() {
// Set up structured JSON logger.
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
store := &ServiceStore{}
mux := http.NewServeMux()
// GET /health - simple liveness check
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
})
// GET /services - list all registered services
mux.HandleFunc("GET /services", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(store.All())
})
// POST /services - register a new service
mux.HandleFunc("POST /services", func(w http.ResponseWriter, r *http.Request) {
var svc Service
if err := json.NewDecoder(r.Body).Decode(&svc); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
if svc.Name == "" || svc.URL == "" {
http.Error(w, `{"error":"name and url are required"}`, http.StatusBadRequest)
return
}
store.Add(svc)
logger.Info("service registered", "name", svc.Name, "url", svc.URL)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(svc)
})
// GET /services/{name}/check - check a single service
mux.HandleFunc("GET /services/{name}/check", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
svc, found := store.FindByName(name)
if !found {
http.Error(w, `{"error":"service not found"}`, http.StatusNotFound)
return
}
result := checkService(svc)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
})
// GET /check-all - check all services concurrently
mux.HandleFunc("GET /check-all", func(w http.ResponseWriter, r *http.Request) {
services := store.All()
if len(services) == 0 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]CheckResult{})
return
}
results := make(chan CheckResult, len(services))
for _, svc := range services {
go func(s Service) {
results <- checkService(s)
}(svc)
}
var allResults []CheckResult
for range services {
allResults = append(allResults, <-results)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(allResults)
})
// Determine port from environment or default to 8080.
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
server := &http.Server{
Addr: ":" + port,
Handler: loggingMiddleware(logger, mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
// Graceful shutdown: listen for SIGINT or SIGTERM.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
logger.Info("server starting", "port", port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}()
// Block until a signal is received.
sig := <-quit
logger.Info("shutting down", "signal", sig.String())
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("shutdown error", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
Your API is feature-complete and production-ready. It handles concurrent requests, logs everything in structured JSON, and shuts down gracefully. Next up, you'll cross-compile it for Linux and launch an EC2 instance to run it on.
Cross-Compile and Launch an EC2 Instance
Your API works perfectly on Windows. To run it on a Linux server in the cloud, you need two things: a Linux-compatible binary and a server to run it on.
Go makes the first part trivial. You can compile a Linux binary directly from your Windows machine with a single environment variable change. For the server, you'll launch an EC2 instance on AWS running Ubuntu.
In this step, get ready to:
- Cross-compile your Go binary for Linux.
- Launch a t3.micro EC2 instance with Ubuntu 24.04 LTS.
- Configure security group rules for SSH, HTTP, and your app port.
Cross-compile the Go binary for Linux
In C++, compiling for a different OS requires a separate toolchain. Go's compiler has cross-compilation built in. You set two environment variables and the compiler produces a statically linked binary for the target platform.
- Make sure you are in your service-monitor project directory in PowerShell.
- Build the Linux binary by running these commands:
$env:GOOS = "linux"
$env:GOARCH = "amd64"
go build -o service-monitor
What do these commands do?
GOOS=linux tells the Go compiler to produce a binary that runs on Linux instead of Windows.
GOARCH=amd64 targets 64-bit x86 processors, which is what EC2 instances use.
The resulting binary is statically linked with zero runtime dependencies. You can copy it to any Linux machine and it just runs. No Go installation needed on the server.
- Confirm the binary was created by running:
ls service-monitor
You should see a file named service-monitor (no .exe extension) listed in the output. This is your Linux binary.
Don't see the file?
Make sure you ran the commands from inside your service-monitor folder (the one containing main.go).
If you see a compile error, check that main.go is saved with no syntax errors. Try running go build . without the environment variables to see if the code compiles normally first.
Why isn't my Go cross-compilation producing a Linux binary?
Why is this easier than C++?
In C++, cross-compiling for Linux from Windows typically requires installing a separate cross-compilation toolchain (like MinGW or WSL). Go's compiler handles all target platforms natively. The same go build command works for any supported OS and architecture combination.
- Reset your environment variables back to Windows defaults by running:
$env:GOOS = ""
$env:GOARCH = ""
Launch a t3.micro EC2 instance
Now you need a Linux server to run this binary on. You'll launch an EC2 instance through the AWS Console. EC2 gives you a virtual machine in the cloud that you can connect to over SSH.
- Open your browser and sign in to the AWS Management Console at https://console.aws.amazon.com.
- In the search bar at the top, type EC2 and select EC2 from the results.
- Click Launch instance.
This opens the instance launch wizard where you'll configure everything on one page.
- Under Name and tags, enter service-monitor-server.
- Under Application and OS Images (Amazon Machine Image), confirm that Ubuntu is selected.
- In the Amazon Machine Image (AMI) dropdown, select Ubuntu Server 24.04 LTS (it should be the default or the first free tier eligible option).
- Under Instance type, select t3.micro.
What is an AMI?
An AMI (Amazon Machine Image) is a template that contains the operating system and pre-installed software for your instance. Ubuntu 24.04 LTS is a long-term support release that will receive security updates for years.
Next, you need an SSH key pair so you can securely connect to your instance from your Windows machine.
- Under Key pair (login), click Create new key pair.
- Enter service-monitor-key as the key pair name.
- Leave the key pair type as RSA.
- Select .pem as the private key file format.
- Click Create key pair.
Your browser downloads a file called service-monitor-key.pem. This is your private key for SSH access. Keep it safe and remember where it was saved (usually your Downloads folder).
Do not lose this file
The .pem file is your only way to SSH into the instance. If you lose it, you cannot reconnect. AWS does not let you download it again.
Configure the security group
A security group acts as a firewall for your instance. It controls which network traffic can reach your server. You need to open three ports: SSH for connecting, HTTP for public web traffic, and 8080 for your Go app.
- Under Network settings, click Edit.
- Confirm that Allow SSH traffic from is checked and set to My IP.
- Check the box for Allow HTTP traffic from the internet.
- Click Add security group rule.
- Set Type to Custom TCP.
- Set Port range to 8080.
- Set Source type to Anywhere (0.0.0.0/0).
Why open port 8080 separately?
Your Go app runs on port 8080 because binding to port 80 requires root privileges. You'll set up Nginx as a reverse proxy later to forward port 80 traffic to 8080. Opening port 8080 now lets you test the app directly before adding Nginx.
- Leave all other settings at their defaults.
- Click Launch instance.
AWS takes a moment to create your instance. You should see a success message with a link to your instance.
- Click the instance ID link to go to your instance details.
- Wait for the Instance state column to show Running (refresh the page if it still says Pending).
- Copy the Public IPv4 address from the instance details panel. You'll need this to connect in the next step.
Instance stuck in Pending?
EC2 instances usually take 30-60 seconds to reach the Running state. Click the refresh icon in the Instances table header to check again.
If the instance shows a different state (Terminated or Error), check the error message. Common causes include hitting your account's instance limit or selecting an instance type not available in your region.
My EC2 instance is not launching successfully. What should I check?
You have a compiled Linux binary on your Windows machine and a running EC2 instance waiting to receive it. Next up, you'll copy the binary to the server and set it up as a production service.
Deploy and Run as a systemd Service
Your Go binary is compiled and your EC2 instance is running. Now it is time to get that binary onto the server and turn it into a production service that starts automatically and recovers from crashes.
Linux servers use systemd to manage long-running processes. You will create a service file that tells systemd how to start your API, when to restart it, and which user runs it.
In this step, get ready to:
- Transfer the compiled binary to EC2 using SCP and connect via SSH.
- Create a systemd service file with restart policies and environment configuration.
- Enable, start, and verify the service with systemctl.
Transfer the binary and SSH into EC2
SCP (Secure Copy Protocol) transfers files over an SSH connection. You will use it to upload your compiled binary from your Windows machine to the EC2 instance.
- Open PowerShell and navigate to your service-monitor project directory (the folder containing your compiled binary).
- Upload the binary to your EC2 instance by running this command:
scp -i [[PEMPATH="C:\Users\YourName\Downloads\service-monitor-key.pem"]] service-monitor ubuntu@[[EC2_IP="your EC2 public IPv4 address"]]:/home/ubuntu/
What does this command do?
- -i specifies the private key file that authenticates you to the server.
- service-monitor is the Linux binary you cross-compiled in the previous step.
- ubuntu@ is the default username for Ubuntu AMIs on EC2.
- :/home/ubuntu/ is the destination directory on the remote server.
PowerShell may ask you to confirm the server's fingerprint the first time you connect. Type yes and press Enter to continue.
- You should see a progress indicator showing the file transfer completing at 100%.
Seeing a permission denied or timeout error?
- Make sure the path to your .pem file is correct. If it is in your Downloads folder, double-check the exact filename matches what you downloaded.
- Confirm your EC2 instance is in the Running state in the AWS Console and that its security group allows SSH on port 22 from your IP.
- Verify you are using the public IPv4 address (not the private IP) from the EC2 instance details.
Help me troubleshoot my SCP connection to EC2
- Now connect to your EC2 instance via SSH by running this command:
ssh -i [[PEMPATH="C:\Users\YourName\Downloads\service-monitor-key.pem"]] ubuntu@[[EC2_IP="your EC2 public IPv4 address"]]
- You should see your terminal prompt change to something like ubuntu@ip-172-x-x-x:~$. You are now inside your Linux server.
- Move the binary to a system-wide location and make it executable by running these commands:
sudo mv /home/ubuntu/service-monitor /usr/local/bin/service-monitor
sudo chmod +x /usr/local/bin/service-monitor
Why move it to /usr/local/bin?
/usr/local/bin/ is the standard Linux directory for locally-installed executables. Placing your binary here keeps it separate from system packages and makes it available system-wide.
- Confirm the binary is in place by running:
ls -l /usr/local/bin/service-monitor
- You should see the file listed with executable permissions (an x in the permissions string).
Create a systemd service file
A systemd service file tells Linux how to manage your application as a background process. It defines what command to run, which user runs it, and what to do if it crashes.
- Create a new service file by running:
sudo nano /etc/systemd/system/service-monitor.service
- This opens the nano text editor. Paste the following content into the file:
[Unit]
Description=Service Monitor API
After=network.target
[Service]
ExecStart=/usr/local/bin/service-monitor
Restart=always
RestartSec=5
User=ubuntu
Environment=PORT=8080
[Install]
WantedBy=multi-user.target
What does each section do?
- [Unit] describes the service. After=network.target ensures the network is ready before your API starts.
- [Service] defines how to run it. Restart=always means systemd restarts your app automatically if it crashes. RestartSec=5 waits 5 seconds between restarts.
- User=ubuntu runs the process as the ubuntu user instead of root, following the principle of least privilege.
- Environment=PORT=8080 sets the PORT environment variable your Go code reads with os.Getenv("PORT").
- [Install] with WantedBy=multi-user.target means the service starts automatically at boot.
- Save the file by pressing Ctrl+O, then Enter to confirm the filename.
- Exit nano by pressing Ctrl+X.
- Verify the file was saved correctly by running:
cat /etc/systemd/system/service-monitor.service
- You should see the exact service file contents printed to your terminal.
File is empty or missing?
- Make sure you used sudo before nano. Without it, you cannot write to /etc/systemd/system/.
- If you accidentally saved to the wrong location, delete it with sudo rm and run the sudo nano command again.
Help me fix my systemd service file
✔️ Awesome, I've got everything!
Great. Make sure the output of cat matches the service file exactly, then continue to the next substep.
ⓧ I'd like to double check the full code
Here is the complete service file for comparison:
[Unit]
Description=Service Monitor API
After=network.target
[Service]
ExecStart=/usr/local/bin/service-monitor
Restart=always
RestartSec=5
User=ubuntu
Environment=PORT=8080
[Install]
WantedBy=multi-user.target
Enable, start, and verify the service
With the service file in place, you need to tell systemd to load it, enable it for boot startup, and start it.
- Reload systemd so it picks up your new service file, then enable and start the service by running these commands:
sudo systemctl daemon-reload
sudo systemctl enable service-monitor
sudo systemctl start service-monitor
What do these commands do?
- daemon-reload tells systemd to re-read all service files. You must run this after creating or modifying a service file.
- enable configures the service to start automatically when the server boots.
- start launches the service immediately.
- Check that the service is running by running:
sudo systemctl status service-monitor
- You should see Active: active (running) in green text. This confirms your Go API is running as a managed service.
- View the live structured logs from your API by running:
sudo journalctl -u service-monitor -f
- You should see JSON log output including the "msg":"server starting" line from your structured logger.
- Press Ctrl+C to stop following the logs.
Why systemd instead of just running the binary directly?
Running a binary directly in an SSH session means it dies the moment you disconnect. systemd keeps the process alive in the background, restarts it if it crashes, and starts it automatically after a server reboot.
It also integrates with journalctl for centralized log management. Your log/slog JSON output flows directly into the system journal where you can filter, search, and rotate logs.
Service status shows failed or inactive?
- Run sudo journalctl -u service-monitor --no-pager to see the full error output.
- Check that ExecStart points to /usr/local/bin/service-monitor and the binary exists at that path.
- Confirm the binary has executable permissions with ls -l /usr/local/bin/service-monitor.
- If you edited the service file to fix something, remember to run sudo systemctl daemon-reload before restarting.
Help me debug why my systemd service is failing
- Verify your API is responding on port 8080 directly by running this from the EC2 instance:
curl http://localhost:8080/health
- You should see a JSON response with {"status":"ok","time":"..."}. Your Go API is live on the server.
Your API is running as a managed service on Linux. It survives SSH disconnects, restarts after crashes, and launches automatically on boot. Next up, you will put Nginx in front of it as a reverse proxy so the world can reach your API on port 80.
Set Up Nginx as a Reverse Proxy
Your Go API is running as a systemd service on port 8080. Right now, anyone who wants to reach it needs to specify that port number in the URL.
In production, web traffic uses port 80 (HTTP) by default. You'll set up Nginx as a reverse proxy that accepts traffic on port 80 and forwards it to your Go app on port 8080.
In this step, get ready to:
- Install Nginx on your EC2 instance.
- Configure Nginx to proxy port 80 traffic to your Go app on port 8080.
- Test the live API from your Windows machine.
Install Nginx on the EC2 instance
Binding to port 80 requires root privileges, but your Go app runs as the unprivileged ubuntu user. Nginx has its own privilege-dropping mechanism that handles this safely.
- In your SSH session on the EC2 instance, update the package list and install Nginx by running these commands:
sudo apt update
sudo apt install nginx -y
What do these commands do?
The first command refreshes the list of available packages from Ubuntu's repositories. The second command installs Nginx and the -y flag automatically confirms the installation prompt.
- Confirm Nginx is running by checking its status:
sudo systemctl status nginx
You should see active (running) in green text. Press q to exit the status view.
Nginx not active?
If the install failed, check that your EC2 instance has internet access. Your security group must allow outbound traffic (the default allows all outbound).
Help me troubleshoot why Nginx failed to install or start on my Ubuntu EC2 instance.
Why Nginx instead of running Go on port 80 directly?
On Linux, only root can bind to ports below 1024. Running your application as root is a security risk. Nginx is purpose-built to handle this: it starts as root, binds to port 80, then drops to an unprivileged worker process.
This pattern also lets you add TLS termination, rate limiting, and load balancing later without changing your Go code.
Configure the Nginx reverse proxy
Nginx uses site configuration files to define how it routes incoming traffic. You will create a config file that tells Nginx to forward all port 80 requests to your Go app on port 8080.
- First, remove the default Nginx site so it does not conflict with your config:
sudo rm /etc/nginx/sites-enabled/default
- Create a new Nginx configuration file for your service monitor by running:
sudo nano /etc/nginx/sites-available/service-monitor
- Paste the following server block into the file:
server {
listen 80;
server_name _;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
What does this config do?
- listen 80 tells Nginx to accept traffic on port 80 (the standard HTTP port).
- server_name _ matches any hostname, so all requests reach your app regardless of what domain they use.
- proxy_pass http://localhost:8080 forwards every request to your Go app.
- The proxy_set_header lines pass the original client's IP and hostname through to Go, so your logs show real client info instead of localhost.
- Save the file by pressing Ctrl+O, then Enter to confirm.
- Exit nano by pressing Ctrl+X.
- Create a symlink to enable the site by running:
sudo ln -s /etc/nginx/sites-available/service-monitor /etc/nginx/sites-enabled/
Why a symlink?
Nginx only reads configs from the sites-enabled directory. By keeping the actual file in sites-available and symlinking it, you can quickly disable the site by removing the link without deleting the config.
- Test that your Nginx config has no syntax errors by running:
sudo nginx -t
You should see syntax is ok and test is successful.
- Restart Nginx to apply the new configuration:
sudo systemctl restart nginx
Seeing a config test failure?
Check that you removed the default site from sites-enabled. Two configs both listening on port 80 will conflict.
Also verify there are no typos in the config file. Open it again with sudo nano /etc/nginx/sites-available/service-monitor and compare against the code above.
Help me fix my Nginx configuration test failure.
Test the live API from your Windows machine
Your API is now accessible on port 80, which means you can reach it using just the EC2 public IP address with no port number required.
- Open a new PowerShell window on your Windows machine (keep the SSH session open in the other window).
- Test the health endpoint by running:
curl http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]/health
You should see a JSON response like this:
{"status":"ok","time":"2026-05-05T12:00:00Z"}
Where do I find my EC2 public IP?
In the AWS Console, go to EC2 > Instances. Select your instance and look for the Public IPv4 address field in the details panel. This is the same IP you used for SSH earlier.
- Try registering a service and checking it concurrently:
curl -X POST http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]/services -H "Content-Type: application/json" -d '{"name":"google","url":"https://www.google.com"}'
curl http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]/check-all
The /check-all response should show Google as healthy with a response time in milliseconds.
Getting connection refused or timeout?
Make sure your security group allows inbound HTTP on port 80 from 0.0.0.0/0. You configured this in the previous step, but double check in the EC2 Console under your instance's Security tab.
Also verify both Nginx and the service-monitor are running by switching to your SSH window and running sudo systemctl status nginx and sudo systemctl status service-monitor.
Help me debug why I cannot reach my Go API through Nginx on EC2.
✔️ I see the JSON response
Your Go API is live on the internet, running as a production systemd service behind an Nginx reverse proxy. This is a real production deployment pattern.
ⓧ I'd like to double check the Nginx config
Here is the complete Nginx config file that should be at /etc/nginx/sites-available/service-monitor:
server {
listen 80;
server_name _;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Secret mission
Background Health Check Worker with Uptime Stats
Your API checks services on demand. But what if it could monitor them automatically in the background and tell you their uptime over time? Add a background worker goroutine and a reporting endpoint to find out.
Clean Up Your Resources
Clean Up Your Resources
Your EC2 instance costs money if left running, even if free tier covers it. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Cost warning
A t3.micro instance uses your free tier monthly hours while running. If your free tier has expired, the instance costs approximately $0.01/hour. Stopping the instance still incurs a few cents per month for EBS storage.
If you are done with this project, terminate the instance to stop all charges.
Resources you used:
- EC2 instance (t3.micro running Ubuntu 24.04).
- Key pair (.pem file stored locally).
- Security group (inbound rules for ports 22, 80, and 8080).
- Nginx and systemd configuration on the instance.
- Local project folder with Go source code and compiled binary.
Keep everything running
No action needed. Choose this if you are still actively experimenting with your API or want to add more features.
- Monitor your free tier usage in the AWS Billing console to avoid surprise charges.
- Your instance will continue running and responding to requests at its public IP.
- Keep your .pem file safe. You need it to SSH back into the instance.
Pause - I'll come back to this later
Stop your instance to free up compute hours while keeping your files and configuration intact. You still pay a few cents per month for EBS storage.
- Open the EC2 Console and click Instances in the left sidebar.
- Select your instance by checking the box next to it.
- Click Instance state, then Stop instance.
- When you are ready to resume, select the instance and choose Instance state > Start instance. Note that the public IP address changes when you restart.
- Keep your local project folder and .pem file. You will need both to reconnect.
Delete - I don't want to use this again
Terminate the instance to delete everything and stop all charges. This is recommended if you are done with the project.
- Open the EC2 Console and click Instances in the left sidebar.
- Select your instance by checking the box next to it.
- Click Instance state, then Terminate instance.
- Confirm the termination when prompted. The instance and its attached EBS volume will be deleted.
- Optionally, navigate to Security Groups in the left sidebar and delete the security group you created for this project.
- Navigate to Key Pairs in the left sidebar and delete the key pair if you no longer need it.
- Delete your local .pem file and optionally remove the project folder from your Desktop.
That's a Wrap!
That's a Wrap!
You deployed a Go service to production on Linux. That is a real skill. You built the API from scratch using only Go's standard library, cross-compiled it for Linux, and set it up as a proper production service on AWS EC2.
Here's what you accomplished:
- Built a REST API using only Go's standard library with goroutines and channels for concurrent health checks and structured JSON logging via log/slog.
- Cross-compiled from Windows to Linux with zero configuration and deployed a statically linked binary to EC2 over SSH with no runtime dependencies.
- Managed the application with systemd for auto-restart and boot startup, and set up Nginx as a reverse proxy for production traffic on port 80.
- Secret Mission: Added background workers that periodically check services and exposed uptime statistics through a /report endpoint.
Ready to quiz yourself?