Module 03 — Go Backend
Objective
Build and deploy a Go REST API on app-server that connects to the PostgreSQL database on db-server and serves customer data.
By the end of this module you will have:
| Component | Detail |
|---|---|
| Go | 1.22 installed on app-server |
| API | REST endpoints for customers CRUD on port 8080 |
| Database | App connects to customerdb on db-server |
| Service | customerapp running via systemd |
Prerequisites
- Module 02 complete — PostgreSQL running on db-server with
customerdb,appuser, and tables created - SSH access to app-server:
ssh app-server
1. Install Go on app-server
SSH into app-server:
ssh app-server
1.1 Download and install Go 1.22
wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
rm go1.22.0.linux-amd64.tar.gz
This downloads the official Go binary release and extracts it to /usr/local/go.
1.2 Add Go to your PATH
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
1.3 Verify the installation
go version
You should see:
go version go1.22.0 linux/amd64
2. Create the project
On app-server, create a directory for the application:
mkdir -p ~/customerapp
The project needs two files: go.mod (declares the module name and dependencies) and main.go (the application code). We will transfer them from your Mac in the next step, but first let's understand what the code does.
3. Walk through main.go
The entire backend is a single Go file. This section walks through each part so you understand how it works before deploying it.
3.1 Package, imports, and structs
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
_ "github.com/lib/pq"
)
var db *sql.DB
type Customer struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
Address string `json:"address"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Message string `json:"message"`
Username string `json:"username"`
}
The database/sql package provides a generic SQL interface, while github.com/lib/pq is the PostgreSQL driver (imported with _ because we only need its side-effect of registering itself). The Customer struct maps directly to columns in the customers table, and the JSON tags control how fields appear in API responses.
3.2 Database connection
connStr := "host=192.168.56.11 port=5432 user=appuser password=apppassword123 dbname=customerdb sslmode=disable"
var err error
db, err = sql.Open("postgres", connStr)
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()
err = db.Ping()
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
log.Println("Connected to database")
sql.Open creates a connection pool but does not actually connect — db.Ping() verifies the connection is reachable. The connection string points to db-server's IP and uses the appuser credentials we created in Module 02.
3.3 Main function and routes
http.HandleFunc("/health", healthHandler)
http.HandleFunc("/api/login", loginHandler)
http.HandleFunc("/api/customers", customersHandler)
http.HandleFunc("/api/customers/", customerByIDHandler)
http.Handle("/", http.FileServer(http.Dir("./static")))
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
Each HandleFunc maps a URL pattern to a handler function. The /api/customers/ pattern (with trailing slash) matches any path that starts with it, which is how we handle requests like /api/customers/1. The file server on / serves the frontend (we will add it in Module 04).
3.4 Health check handler
func healthHandler(w http.ResponseWriter, r *http.Request) {
err := db.Ping()
// returns {"status":"healthy","database":"connected"} or unhealthy
}
A simple endpoint that pings the database and returns a JSON status. This is useful for verifying the API and database connection are working.
3.5 Login handler
func loginHandler(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
json.NewDecoder(r.Body).Decode(&req)
query := fmt.Sprintf("SELECT id, username FROM users WHERE username = '%s' AND password = '%s'",
req.Username, req.Password)
row := db.QueryRow(query)
// ...
http.SetCookie(w, &http.Cookie{Name: "session", Value: username, Path: "/"})
}
The login handler parses a JSON body, queries the users table, and sets a session cookie on success. Protected endpoints check for this cookie before allowing access.
3.6 List customers handler
func listCustomersHandler(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query("SELECT id, name, email, phone, address, created_at, updated_at FROM customers ORDER BY id")
// iterate rows, scan into Customer structs, return JSON array
}
Queries all customers ordered by ID and returns them as a JSON array. The rows.Next() loop scans each database row into a Customer struct.
3.7 Get customer handler
func getCustomerHandler(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/customers/")
query := fmt.Sprintf("SELECT id, name, email, phone, address, created_at, updated_at FROM customers WHERE id = %s", id)
row := db.QueryRow(query)
// scan and return single customer
}
Extracts the customer ID from the URL path and queries for that specific customer. Returns a 404 if not found.
3.8 Create customer handler
func createCustomerHandler(w http.ResponseWriter, r *http.Request) {
var c Customer
json.NewDecoder(r.Body).Decode(&c)
query := fmt.Sprintf("INSERT INTO customers (name, email, phone, address) VALUES ('%s', '%s', '%s', '%s') RETURNING ...",
c.Name, c.Email, c.Phone, c.Address)
// execute and return created customer with 201 status
}
Parses a JSON body into a Customer struct and inserts a new row. The RETURNING clause gives us the complete record (including the auto-generated ID and timestamps) without a second query.
3.9 Update customer handler
func updateCustomerHandler(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/customers/")
var c Customer
json.NewDecoder(r.Body).Decode(&c)
query := fmt.Sprintf("UPDATE customers SET name = '%s', email = '%s', phone = '%s', address = '%s', updated_at = NOW() WHERE id = %s RETURNING ...",
c.Name, c.Email, c.Phone, c.Address, id)
// execute and return updated customer
}
Combines the ID from the URL with fields from the JSON body to update the record. Returns the updated customer or a 404 if the ID does not exist.
3.10 Delete customer handler
func deleteCustomerHandler(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/customers/")
query := fmt.Sprintf("DELETE FROM customers WHERE id = %s", id)
result, err := db.Exec(query)
// check RowsAffected, return success or 404
}
Deletes the customer with the given ID. Uses RowsAffected() to detect whether the customer existed — if zero rows were affected, it returns a 404.
4. Transfer code to app-server
On your Mac (not inside an SSH session), copy the source files to app-server:
scp src/backend/* app-server:~/customerapp/
This copies both go.mod and main.go to the ~/customerapp directory on app-server.
5. Install dependencies and build
SSH into app-server and build the application:
ssh app-server
cd ~/customerapp
5.1 Download dependencies
go mod tidy
This reads go.mod, downloads the lib/pq driver, and creates a go.sum file with checksums for integrity verification.
5.2 Build the binary
go build -o customerapp .
This compiles the application into a single binary called customerapp. Go produces statically-linked binaries, so there are no runtime dependencies to install.
6. Run and test
6.1 Run the application
./customerapp
You should see:
2024/01/01 12:00:00 Connected to database
2024/01/01 12:00:00 Server starting on :8080
Leave this running and open a new terminal on your Mac to test the endpoints.
6.2 Health check
curl http://192.168.56.12:8080/health
Expected response:
{"database":"connected","status":"healthy"}
6.3 Login
curl -X POST http://192.168.56.12:8080/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
Save the cookie for subsequent requests:
curl -c cookies.txt -X POST http://192.168.56.12:8080/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
6.4 Create a customer
curl -b cookies.txt -X POST http://192.168.56.12:8080/api/customers \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","email":"john@example.com","phone":"555-0100","address":"123 Main St"}'
Expected: a JSON object with the new customer including an auto-assigned id.
6.5 List all customers
curl -b cookies.txt http://192.168.56.12:8080/api/customers
Expected: a JSON array containing the customer you just created.
6.6 Get a single customer
curl -b cookies.txt http://192.168.56.12:8080/api/customers/1
6.7 Update a customer
curl -b cookies.txt -X PUT http://192.168.56.12:8080/api/customers/1 \
-H "Content-Type: application/json" \
-d '{"name":"John Updated","email":"john@example.com","phone":"555-0100","address":"456 Oak Ave"}'
6.8 Delete a customer
curl -b cookies.txt -X DELETE http://192.168.56.12:8080/api/customers/1
Expected:
{"message":"Customer deleted successfully"}
Once all endpoints work, press Ctrl+C to stop the application. We will set it up as a service next.
7. Set up systemd service
Running the application manually is fine for testing, but in production you want it to start automatically and restart on failure.
7.1 Create the service file
sudo tee /etc/systemd/system/customerapp.service > /dev/null <<EOF
[Unit]
Description=Customer App Go Backend
After=network.target
[Service]
Type=simple
User=trainee
WorkingDirectory=/home/trainee/customerapp
ExecStart=/home/trainee/customerapp/customerapp
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
This tells systemd to run the binary as the trainee user, restart it if it crashes, and start it on boot.
7.2 Enable and start the service
sudo systemctl daemon-reload
sudo systemctl enable customerapp
sudo systemctl start customerapp
7.3 Verify the service is running
sudo systemctl status customerapp
You should see active (running). You can also check the logs:
sudo journalctl -u customerapp -f
7.4 Test again
From your Mac, verify the API still works through the systemd service:
curl http://192.168.56.12:8080/health
Troubleshooting
"dial tcp 192.168.56.11:5432: connect: connection refused"
The app cannot reach PostgreSQL. Verify:
- PostgreSQL is running on db-server:
sudo systemctl status postgresql pg_hba.confallows connections from 192.168.56.12 (see Module 02, section 4)postgresql.confhaslisten_addresses = '*'(see Module 02, section 4)- Test from app-server:
psql -h 192.168.56.11 -U appuser -d customerdb
"go: command not found"
Go is not in your PATH. Run:
export PATH=$PATH:/usr/local/go/bin
Then add it to ~/.bashrc permanently (see section 1.2).
"cannot find package github.com/lib/pq"
Run go mod tidy in the project directory to download dependencies.
"listen tcp :8080: bind: address already in use"
Another process is using port 8080. Find and stop it:
sudo lsof -i :8080
sudo kill <PID>
Service fails to start
Check logs for details:
sudo journalctl -u customerapp --no-pager -n 50
Common causes: wrong WorkingDirectory path, missing binary, database not reachable.
Summary
You now have a working Go REST API running on app-server that connects to PostgreSQL on db-server. The API provides full CRUD operations for customers and a simple cookie-based login system. In the next module we will build the frontend that talks to this API.