Package your SaaS frontend for self-hosting using Golang

Sometimes you need to allow for self-hosted subscription of your SaaS whether it's because your client has their own air-gapped system that is not connected to the internet or they want to host it in their on-premise servers. Having the option is always a good thing.
Why do you need a single Executable?
Why don't you share the whole dist folder as a zip so it can be hosted by the client? sharing the actual files increases exposure of your code to be reverse engineered. It will also be easier for people to change the code, for example, to avoid paying your subscription. An experienced hacker will still find a way but this will create a new layer of security because your javascript files are not visible in the host machine.
I'm using React as an example for this demo but you can use your favorite frontend stack by making the necessary go code changes. If you are interested on seeing your favorite stack deployed this way, please leave a comment below.
Let's setup a demo React app
Please skip to the next section if you already have your react app set up. In this example, we are using vite
to create the react app.
npm create vite@latest react-example-app -- --template react
Let's go in the newly created react project and complete the setup.
cd react-example-app
npm install # install dependencies
npm run dev # run the app locally
Let's go on the default URL on http://localhost:5173 to see if our react app is running.
Now we have the app running, let's setup a go package, this is only a single file go project so you can create it within your frontend project.
Let's create a serve.go
file at the root of our project, then add the following content:
// serve.go
package main
import (
"embed"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"github.com/gin-gonic/gin"
)
// this contains special comments that instruct the go compiler to embed the files within the executable.
var (
//go:embed dist/*
dist embed.FS
//go:embed dist/index.html
indexHTML string
//go:embed dist/assets/*
assets embed.FS
IndexTemplate *template.Template
)
// this is called before any other code in this file runs
func init() {
// setup HTML renderer with the embedded index.html
IndexTemplate = template.New("index.html")
var err error
IndexTemplate, err = IndexTemplate.Parse(indexHTML)
if err != nil {
panic(err)
}
}
func main() {
// create gin router
router := gin.Default()
// set the embedded index.html as the go template
router.SetHTMLTemplate(IndexTemplate)
// this gives you the option the change the port after the executable is created
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// static files in dist/assets
assetsFs, err := fs.Sub(assets, "dist/assets")
if err != nil {
panic(err)
}
// serve the contents of assets folder in /assets URL path
router.StaticFS("/assets", http.FS(assetsFs))
// handle all routes
router.NoRoute(func(c *gin.Context) {
// NOTE: if you're running an API alongside your frontend, you'll want to do the following to skip the /api endppoints
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Next()
return
}
c.HTML(http.StatusOK, "index.html", gin.H{})
})
log.Printf("Server is running on port %s", port)
if err := router.Run(":" + port); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
Before we can run the go code, we need to initialize the go package. We named it web-go-server
but you can call it anything you want.
go mod init web-go-server # sets up the required files for t
go mod tidy # to initialize imported packages in go.sum, this is like the package.json for go projects.
Let's test what we have so far.
# make sure you build the fontend, or there will be no dist folder to embed in the executable.
# you'll get this error if you skip this step:
# serve.go:15:13: pattern dist/*: no matching files found
npm run build
# then run the go code
go run .
Now we have our frontend served through our go code, the static files are going to be embedded within the executable file so we'll leave the dist
folder behind and don't need to share alongside the executable.
As you can see in the screenshot below, we are missing the vite.svg
file this is from the public folder, we can't serve the contents of the whole dist folder as the root because that would conflict with the index.html we serve from the root path /
to work around this we need to move the public/vite.svg
to src/assets/vite.svg
you do the same for any other static file you have in the public folder. In case you don't want to move any file to the assets place them in public/static
so we can modify the go code to serve that as well.
// static files in dist/static
staticFs, err := fs.Sub(dist, "dist/static")
if err != nil {
panic(err)
}
router.StaticFS("/static", http.FS(staticFs))
Then edit anywhere the static file is referred to in your frontend code and make sure all images are showing (Screenshot below).
What do we have?
This is how the whole project is structured:
.
├── README.md
├── eslint.config.js
├── go.mod
├── go.sum
├── index.html
├── package.json
├── pnpm-lock.yaml
├── public
│ └── vite.svg
├── src
│ ├── App.css
│ ├── App.jsx
│ ├── assets
│ │ └── react.svg
│ ├── index.css
│ └── main.jsx
├── vite.config.js
└── serve.go
Let's create the executable binary file
After you have everything setup and everything is working when you run go run .
you need to create the executable you share with your clients.
# run the fontend build again to make sure no changes left out
npm run build
# build the go executable
go build -o your-app-name .
To build for multiple platforms:
The above command creates an executable that runs on your current machine and any machine that is using the same CPU architecture but Golang allows you to cross-compile to all supported CPUs using your current machine.
# for windows
GOOS=windows GOARCH=amd64 go build -o your-app-name.exe .
# for linux x86
GOOS=linux GOARCH=amd64 go build -o your-app-name .
# for linux arm64 like rasbperry pi
GOOS=linux GOARCH=arm64 go build -o your-app-name .
# for macos intel x86 64
GOOS=darwin GOARCH=amd64 go build -o your-app-name .
# for macos Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o your-app-name .
I hope this helped you ship your SaaS fast using this method, if you like this kind of content please feel free to follow me and subscribe to my newsletter.
Subscribe to my newsletter
Read articles from Mahad Ahmed directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Mahad Ahmed
Mahad Ahmed
Mahad loves building mobile and web applications and is here to take you on a journey, filled with bad decisions and learning from mistakes, through this blog.