Recently I've become quite a fan of Go, a pragmatic and flexible programming language that has opened up new ways for me to build applications.
In this post, I wanna show how to embed an entire JavaScript/TypeScript SPA (Single Page Application) directly into your go binary to simplify the deployment of small applications. I've used this approach already in multiple smaller projects to streamline the build and deployment pipeline.
What problem are we solving here?
The "SPA with backend" architecture consists of two components that are deployed in very different ways: The backend is a single binary or container providing it's own HTTP service, while the frontend is a bunch of static files.
If you're using a reverse proxy that cannot serve files from the filesystem (like Traefik or some hosted solution in the cloud), you have to deploy a file-serving HTTP server like nginx as well and provide the files to it. Setting this up can be annoying in container-based environments.
This also creates an inconsistent build and deployment experience, which often leads to complex CI setups or additional scripting to put everything into the right place. If you're using a container-based GitOps approach, the SPA part doesn't really fit into the otherwise very standardized picture.
CI/CD steps with separated frontend and backend pipelines.
By embedding the entire SPA into your webservice, you completely get rid of that complexity: Your frontend and backend become a single unit from operations perspective.
CI/CD steps with combined frontend and backend build and deployment process.
This is made possible by Go's embed functionality, which allows you to add files or entire directories into the final binary. Depending on your deployment environment, you might not even need containers and can just run the resulting binary using systemd.
When shouldn't you do that?
If you're building a large-scale or global application that is deployed to multiple containers or servers, it'd recommend against this approach. There are multiple reasons for that:
- When using rolling updates, different users might receive different versions of your app. Depending on your load balancer setup, users might even receive randomly files of different applications versions, causing breakages that are impossible to debug.
- Properly configuring caching is harder.
- Adding huge files or downloads will blow up your binary size.
- You create a closer dependency between backend and frontend, which might not be desired if both parts are developed by different teams.
In these cases, instead go for a CDN like Cloudflare or Bunny.
The setup
Let's start by creating some boilerplate projects. To be able to follow, you should have these tools installed:
Now initialize a demo project:
Build, embed and register
For this example, I'm using the fiber framework, which has served me well for multiple projects.
To not make my main.go
files become too cluttered, I like to put certain routes into separate files and use registration functions for the routes.
But before, build your JS application once and copy it over into the backend:
You need to repeat these steps every time you update your frontend. Putting that into a Makefile or bash script can help your sanity :)
Now, create the file where the magic happens:
In your main file, you can now register the app route:
Building everything as container
Using the multi-stage feature provided by modern container builders, you can build the frontend and backend binary in a single build step. The resulting container will be quite minimal and only contain your binary and some essential files.
The resulting container image is generally quite small and can be run in any container runtime.
In my most recent project, the resulting container image weighted around 41 MiB and the application itself used around 5 MiB of RAM during runtime.