I've deployed this stack 15 times and crashed production twice. Here are the painful lessons that'll save you a weekend of debugging and one very angry phone call from your boss.

Deployment and Operations

Template Caching Will Destroy Your Saturday
The most embarrassing production bug I ever shipped: templates cached in production but reloaded in dev, so my changes worked locally but were invisible on production. Spent 4 hours convinced the deployment was broken.
Here's what actually works in production (after breaking it multiple ways):
// Template setup that won't randomly cache
func setupTemplates() *template.Template {
// Development: reload on every request
if os.Getenv("ENV") == "dev" {
return template.Must(template.ParseGlob("templates/*.html"))
}
// Production: load once at startup, cache forever
tmpl := template.New("").Funcs(template.FuncMap{
"formatDate": func(t time.Time) string {
return t.Format("Jan 2, 2006") // This will break on other locales
},
})
return template.Must(tmpl.ParseGlob("templates/*.html"))
}
File Structure That Doesn't Suck:
templates/
├── layout.html # One base template, stop overthinking
├── pages/ # Full page templates
│ ├── dashboard.html
│ └── contacts.html
└── fragments/ # HTMX fragments only
├── contact-list.html # Table rows, form responses
└── alerts.html # Error/success messages
Don't make 47 tiny templates like React components. Go templates aren't components. This is the template that crashed production because I tried to be clever:
{{define "contact-form"}}
<!-- This looks fine but breaks with CSRF tokens -->
<form hx-post="/contacts"
hx-target="#contact-list"
hx-swap="afterbegin">
<input type="text" name="name" placeholder="Name" required>
<input type="email" name="email" placeholder="Email" required>
<button type="submit">Add Contact</button>
</form>
{{end}}
The fix that actually works:
{{define "contact-form"}}
<form hx-post="/contacts"
hx-target="#contact-list"
hx-swap="afterbegin"
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'> <!-- This line saves your ass -->
<input type="text" name="name" placeholder="Name" required>
<input type="email" name="email" placeholder="Email" required>
<button type="submit">Add Contact</button>
</form>
{{end}}
HTMX Error Handling (The Part That Breaks at 2am)
HTMX errors are silent by default. Your users click buttons and nothing happens. No console errors, no feedback, just crickets. This cost me 2 hours of debugging on a Sunday night. Check the HTMX response error documentation and error handling patterns:
func contactsHandler(c echo.Context) error {
contacts, err := getContacts(c.QueryParam("search"))
if err != nil {
// Don't do this - error disappears into the void
return c.JSON(500, map[string]string{"error": err.Error()})
}
data := map[string]interface{}{
"Contacts": contacts,
"Search": c.QueryParam("search"),
}
if c.Request().Header.Get("HX-Request") == "true" {
return c.Render(http.StatusOK, "contact-list", data)
}
return c.Render(http.StatusOK, "contacts-page", data)
}
Here's what actually works when shit breaks:
func contactsHandler(c echo.Context) error {
contacts, err := getContacts(c.QueryParam("search"))
if err != nil {
// Log the actual error for debugging
c.Logger().Error("Database error:", err)
if isHTMXRequest(c) {
// Return HTML error that HTMX can display
return c.HTML(http.StatusInternalServerError,
`<div class="alert alert-error">Something broke. Try again.</div>`)
}
// Full page error for regular requests
return c.Render(http.StatusInternalServerError, "error-page",
map[string]interface{}{"Error": "Database connection failed"})
}
// ... rest of handler
}
func isHTMXRequest(c echo.Context) bool {
return c.Request().Header.Get("HX-Request") == "true"
}
Add this to your main template to see HTMX errors in the console:
<body hx-on:htmx:response-error="console.log('HTMX Error:', event.detail.xhr.responseText)">
Alpine.js Scope Hell (Why Your Dropdowns Break)
Alpine.js components can't see each other. This seems obvious until you spend 3 hours debugging why clicking one button doesn't affect another button 10 pixels away. Read the Alpine.js component scoping guide and data sharing patterns.
This Breaks Mysteriously:
<!-- Two separate Alpine components that can't communicate -->
<div x-data="{ showFilters: false }">
<button @click="showFilters = !showFilters">Toggle Filters</button>
</div>
<div x-data="{ selectedFilters: [] }">
<!-- This div has no idea about showFilters -->
<div x-show="showFilters" class="filter-panel"> <!-- Always hidden! -->
<input type="checkbox" x-model="selectedFilters" value="active">
</div>
</div>
This Actually Works:
<!-- Single Alpine component with all related state -->
<div x-data="{
showFilters: false,
selectedFilters: [],
submitting: false
}">
<button @click="showFilters = !showFilters">Toggle Filters</button>
<div x-show="showFilters" class="filter-panel">
<input type="checkbox" x-model="selectedFilters" value="active">
<form hx-post="/filter"
hx-on::before-request="submitting = true"
hx-on::after-request="submitting = false">
<button type="submit" :disabled="submitting">
<span x-show="!submitting">Apply Filters</span>
<span x-show="submitting">Applying...</span>
</button>
</form>
</div>
</div>
For Complex State (Don't Fight Alpine):
// Use Alpine.store() for global state
document.addEventListener('alpine:init', () => {
Alpine.store('filters', {
visible: false,
selected: [],
toggle() {
this.visible = !this.visible;
},
clear() {
this.selected = [];
}
});
});
<!-- Access global state from any component -->
<button @click="$store.filters.toggle()">Toggle Filters</button>
<div x-show="$store.filters.visible">Filters are visible!</div>
Deployment Pain That'll Ruin Your Weekend
The first time I deployed this stack, I broke production for 2 hours because I didn't know about file permissions. Here's what actually breaks. Check the Go deployment best practices and systemd service configuration:
Static Files 404 Errors:
## This breaks in production (file permissions)
COPY static/ /app/static/
RUN chmod 755 /app/static # Wrong! Directories need execute permission
## This works
COPY static/ /app/static/
RUN chmod -R 755 /app/static/ # Recursive permissions for directories and files
Environment Variables That Kill Your App:
// This crashes on startup in production
func main() {
port := os.Getenv("PORT") // Empty string in production
e.Logger.Fatal(e.Start(":" + port)) // Starts on port :
}
// This doesn't crash
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080" // Default fallback
}
e.Logger.Fatal(e.Start(":" + port))
}
The Binary That Won't Start:
## Cross-compilation for Linux deployment (from Mac/Windows)
GOOS=linux GOARCH=amd64 go build -o app main.go
## Upload and run
scp app user@server:/opt/app
ssh user@server "/opt/app" # Crashes with "permission denied"
## Fix permissions
ssh user@server "chmod +x /opt/app && /opt/app"
Production Checklist (So You Don't Break Shit):
The Deploy Script That Actually Works:
#!/bin/bash
## deploy.sh - Copy this exactly
set -e # Exit on any error
## Build for Linux
GOOS=linux GOARCH=amd64 go build -o app main.go
## Deploy
scp app user@yourserver:/tmp/app-new
scp -r static/ user@yourserver:/tmp/static-new
## Atomic deployment (reduces downtime)
ssh user@yourserver "
sudo systemctl stop myapp || true
mv /opt/myapp/app /opt/myapp/app.bak || true
mv /tmp/app-new /opt/myapp/app
chmod +x /opt/myapp/app
rm -rf /opt/myapp/static.bak || true
mv /opt/myapp/static /opt/myapp/static.bak || true
mv /tmp/static-new /opt/myapp/static
chmod -R 755 /opt/myapp/static
sudo systemctl start myapp
"
echo "Deployed successfully"
For more deployment patterns, check the Go deployment guide, SSH deployment best practices, and the Go compilation documentation for cross-platform builds. The systemd service documentation covers service configuration, while Linux file permissions guide explains chmod usage. Consider reading the nginx reverse proxy setup for production deployments and the Linux process management guide for service control.
Systemd Service That Doesn't Crash:
## /etc/systemd/system/myapp.service
[Unit]
Description=My GOAT Stack App
After=network.target
[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/app
Restart=always
RestartSec=5
Environment=ENV=production
Environment=PORT=8080
[Install]
WantedBy=multi-user.target
The GOAT stack deploys easily once you've broken it twice. Single binary deployment is still way better than Docker orchestration hell, npm install failures, or PHP dependency conflicts.