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.
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.
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, notlocalhost: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 adeploy.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