Preface

Concept over tools

Don't get too hung up on the fact that we are using Go, React, Fedora, and Ubuntu in these examples. You can swap them out for Node.js, Vue, Arch Linux, or Debian and the core principles will remain exactly the same. The goal of this article is to guide you through the natural evolution of an application's lifecycle, shifting from a simple monolithic setup to a highly scalable, automated cloud infrastructure.

1. The Foundation (Current Setup) One machine hosting both Apache and the Go app.
โ†“
2. Decentralized Tiering Separating concerns: Apache on one machine, Go on another.
โ†“
3. Microservices & Routing An API Gateway routing traffic to entirely different services.
โ†“
4. High Availability (Horizontal Scaling) A Load Balancer distributing traffic across identical app replicas.
โ†“
5. Cloud-Native Orchestration Containerization via Kubernetes for automated scaling and self-healing.

Architecture Overview

Both Apache and the Go backend run on the same Ubuntu server. Apache acts as a reverse proxy, it serves the React static files directly and forwards /api/* requests to Go, stripping the /api prefix before doing so. Go only ever sees clean paths like /todos.

Proxy architecture

Keeping Go behind Apache means the backend is never directly exposed to the internet, HTTPS is handled once in Apache, and the browser treats everything as same-origin so no CORS configuration is needed in production.
(You might wonder if running Apache and Go on the same machine is redundant, but keeping your application server shielded from direct exposure is a production best practice. While we will cover scaling this architecture across multiple servers later, mastering this single-machine foundational setup is incredibly valuable.)

Cross-compile Go on Fedora

Go compiles to a single static binary with no runtime dependencies. You build once on Fedora and drop the binary on any Linux server. Go installation is not needed on the server.

Install Go on Fedora

sudo dnf install golang -y
go version

Build the binary targeting Linux

# Run inside your Go project folder on Fedora
GOOS=linux GOARCH=amd64 go build -o myapp-backend ./main.gobash

This produces a file called myapp-backend, which is a self-contained Linux binary ready to copy to the server.

GOOS=linux

Target operating system. Use linux for Ubuntu server.

GOARCH=amd64

Target CPU architecture. Use amd64 for standard 64-bit servers.

Build the React Frontend

Build React locally so the output is just static files(HTML, JS, CSS) that Apache can serve without any Node.js on the server.

Install Node.js on Fedora

sudo dnf install nodejs npm -y
node --versionbash

Build the app

# In your React project folder on Fedora
npm install
npm run buildbash

This creates a dist/ folder (Vite) or build/ folder (Create React App) containing your complete frontend. The index.html inside it is what Apache will serve.

Important

Make sure your React code uses a relative API path, not localhost:8080. Set const API = "/api" so all requests go through Apache's proxy.
// App.jsx โ€” correct API base URL
const API = "/api";

// Results in: fetch("/api/todos")
// Apache: /api/todos โ†’ strips /api โ†’ Go receives /todosjsx

Copy Files to the Server via SCP

Use scp (secure copy) to transfer files from your Fedora machine to the Ubuntu server over SSH.

Create directories on the server first

# Run on the Ubuntu server โ€” one time setup
sudo mkdir -p /opt/myapp /var/www/myapp/distbash

Copy the Go binary

# Run on your Fedora machine
scp myapp-backend user@ubuntu-server-ip:/opt/myapp/myapp-backendbash

Copy the React dist folder

scp -r ./dist/* user@ubuntu-server-ip:/var/www/myapp/dist/bash

Fix permissions on the server

# Run on the Ubuntu server after copying
sudo chown -R www-data:www-data /var/www/myapp/dist
sudo find /var/www/myapp/dist -type d -exec chmod 755 {} \;
sudo find /var/www/myapp/dist -type f -exec chmod 644 {} \;

sudo chown root:root /opt/myapp/myapp-backend
sudo chmod +x /opt/myapp/myapp-backendbash

Run Go as a systemd Service

Register the binary as a systemd service so it starts automatically on boot and restarts if it crashes.

sudo nano /etc/systemd/system/myapp-backend.servicebash
[Unit]
Description=My Go Backend
After=network.target

[Service]
ExecStart=/opt/myapp/myapp-backend
WorkingDirectory=/opt/myapp
Restart=always
User=www-data

[Install]
WantedBy=multi-user.targetsystemd
sudo systemctl daemon-reload
sudo systemctl start myapp-backend
sudo systemctl enable myapp-backend

# Verify it's running and listening on port 8080
sudo systemctl status myapp-backend
curl http://localhost:8080/todos    # should return []bash

Configure Apache as Reverse Proxy

Apache serves the React static files and forwards /api/* requests to Go. The ProxyPass rule strips /api before forwarding, Go only sees /todos.

Enable required modules

sudo a2enmod proxy proxy_http rewrite headers
sudo systemctl restart apache2bash

Create the site config

sudo vi /etc/apache2/sites-available/myapp.confbash
<VirtualHost *:80>
    ServerName ubuntu-server.SDWINS.LOCAL
    DocumentRoot /var/www/myapp/dist

    <Directory /var/www/myapp/dist>
        Options -Indexes +FollowSymLinks
        AllowOverride None
        Require all granted

        # React Router โ€” send all unknown routes to index.html
        RewriteEngine On
        RewriteBase /
        RewriteRule ^index\.html$ - [L]
        RewriteCond %{REQUEST_FILENAME} !-f
        RewriteCond %{REQUEST_FILENAME} !-d
        RewriteRule . /index.html [L]
    </Directory>

    # Strip /api before forwarding to Go
    # Browser: POST /api/todos  โ†’  Go receives: POST /todos
    ProxyPreserveHost On
    ProxyPass        /api/ http://127.0.0.1:8080/
    ProxyPassReverse /api/ http://127.0.0.1:8080/

    ErrorLog  ${APACHE_LOG_DIR}/myapp-error.log
    CustomLog ${APACHE_LOG_DIR}/myapp-access.log combined
</VirtualHost>apache

Note

ServerName is the hostname of my ubuntu server which is a part of domain SDWINS.LOCAL.(If you don't know about domains click here to learn about active directory and domains.)

Enable and reload

sudo a2dissite 000-default.conf
sudo a2ensite myapp.conf
sudo apache2ctl configtest       # must say: Syntax OK
sudo systemctl reload apache2bash

Final Go & React Code

Routes use /todos, not /api/todos, because Apache strips /api before forwarding. The path extraction in the handler must match this.

main.go

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
    "strings"
    "sync"
)

type Todo struct {
    ID   int    `json:"id"`
    Text string `json:"text"`
    Done bool   `json:"done"`
}

var (
    todos  []Todo
    nextID = 1
    mu     sync.Mutex
)

func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next(w, r)
    }
}

func todosHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")

    // Apache strips /api, so path arrives as /todos or /todos/1
    path := strings.TrimPrefix(r.URL.Path, "/todos")
    path  = strings.Trim(path, "/")

    if path != "" {
        id, err := strconv.Atoi(path)
        if err != nil {
            http.Error(w, "invalid id", http.StatusBadRequest)
            return
        }
        switch r.Method {
        case http.MethodPut:
            var body struct {
                Done *bool  `json:"done"`
                Text string `json:"text"`
            }
            if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
                http.Error(w, "bad request", http.StatusBadRequest)
                return
            }
            mu.Lock(); defer mu.Unlock()
            for i, t := range todos {
                if t.ID == id {
                    if body.Done != nil { todos[i].Done = *body.Done }
                    if body.Text != ""  { todos[i].Text =  body.Text  }
                    json.NewEncoder(w).Encode(todos[i])
                    return
                }
            }
            http.Error(w, "not found", http.StatusNotFound)
        case http.MethodDelete:
            mu.Lock(); defer mu.Unlock()
            for i, t := range todos {
                if t.ID == id {
                    todos = append(todos[:i], todos[i+1:]...)
                    w.WriteHeader(http.StatusNoContent)
                    return
                }
            }
            http.Error(w, "not found", http.StatusNotFound)
        }
        return
    }

    switch r.Method {
    case http.MethodGet:
        mu.Lock(); defer mu.Unlock()
        json.NewEncoder(w).Encode(todos)
    case http.MethodPost:
        var body struct { Text string `json:"text"` }
        if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Text == "" {
            http.Error(w, "text required", http.StatusBadRequest)
            return
        }
        mu.Lock(); defer mu.Unlock()
        t := Todo{ID: nextID, Text: body.Text, Done: false}
        nextID++
        todos = append(todos, t)
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(t)
    }
}

func main() {
    // /todos โ€” Apache strips /api before forwarding
    http.HandleFunc("/todos/", corsMiddleware(todosHandler))
    http.HandleFunc("/todos",  corsMiddleware(todosHandler))
    log.Println("Backend running on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}go

App.jsx (key parts)

const API = "/api";   // Apache proxies /api/* โ†’ Go

// All fetch calls use relative paths:
fetch(`${API}/todos`)                         // GET    all todos
fetch(`${API}/todos`,   { method: "POST"   })  // POST   new todo
fetch(`${API}/todos/1`, { method: "PUT"    })  // PUT    toggle/edit
fetch(`${API}/todos/1`, { method: "DELETE" })  // DELETE removejsx

Redeployment Workflow

Every time you make changes, run these commands from your Fedora machine.

Redeploy the Go backend

# 1. Rebuild on Fedora
GOOS=linux GOARCH=amd64 go build -o myapp-backend ./main.go

# 2. Copy binary to server
scp myapp-backend user@ubuntu-server-ip:/opt/myapp/myapp-backend

# 3. Restart the service on the server
ssh user@ubuntu-server-ip "sudo systemctl restart myapp-backend"bash

Redeploy the React frontend

# 1. Rebuild on Fedora
npm run build

# 2. Copy dist to server
scp -r ./dist/* user@ubuntu-server-ip:/var/www/myapp/dist/

# 3. Fix permissions on server
ssh user@ubuntu-server-ip "sudo chown -R www-data:www-data /var/www/myapp/dist"bash

Tip

Put both blocks into a deploy.sh script on your Fedora machine so you can redeploy everything with a single command.

Troubleshooting Reference

Symptom Likely cause Fix
Apache default page shows 000-default.conf still active sudo a2dissite 000-default.conf
403 Forbidden Wrong permissions or missing Require all granted Run chown and chmod commands from Step 3
404 on frontend routes React build files not in DocumentRoot Check ls /var/www/myapp/dist โ€” copy files if missing
404 on API calls Go routes use wrong prefix Routes must be /todos, not /api/todos
ERR_CONNECTION_REFUSED React calling localhost:8080 directly Set const API = "/api" in React and rebuild
502 Bad Gateway Go backend not running sudo systemctl status myapp-backend

Useful log commands

# Apache error log
sudo tail -f /var/log/apache2/myapp-error.log

# Go backend logs
sudo journalctl -u myapp-backend -f

# Check what's listening on which ports
sudo ss -tlnp | grep -E '80|8080'

# Verify Apache config is valid
sudo apache2ctl configtest

# Test Go backend directly, bypassing Apache
curl http://localhost:8080/todosbash