As you probably already know from recent posts, in Devforth we love to simplify and automate everything that could be automated. Especially when it comes to software development tools which we use every day in our professional journey. Simplification of configurations eliminates tedious work and allows to turn development process into enjoyable and creative time.

In this post we will share another tool to serve Single Page Applications without pain which we have recently open-sourced.


The story begins at place where we started using Traefik HTTP proxy instead of Nginx. Previously Nginx was our favorite all-in-one tool for HTTP traffic routing and SPA serving at the same time.

After we discovered how beautiful Traefik is in terms of simplicity of installation and maintenance we made our decision. Thing is that Traefik is even does not require a mandatory config to setup a routing between multiple upstreams in Docker Compose, you just use docker labels on containers to define a route.

Nginx vs Traefik brief comparison
Nginx vs Traefik brief comparison

One thing that seamed to be a stopper was a fact that Traefik does not support static files serving out of the box. There are a lot of options to workaround it, someone even put Nginx after Traefik only to serve static.

In the end we came with our own lightweight Go-based image, absolutely simply configurable, compatible with most common setups. Meet spa-to-http adapter. We open-sourced it under MIT license so you can use it in any commercial project.

Disclaimer: Politics explanation about russia
During couple of last months, russian bastards ruthlessly killed several our good friends from Ukraine using their f*ing missiles. Our friends were really good guys, they were "out of politics", also missile fragments wounded their children. We have nothing personal against Igor Sysoev, he is probably a good guy too, and struggled a lot from russian corruption, but anyway any positive mentions of "russia" word might indirectly lead to economical profits of country who makes a missiles and directs them to civils.

Example using spa-to-http 

In example we will show how to deploy Vue.js app, but you can easily adopt it for any SPA Framework.

First, let's create a folder where we will work:

mkdir trfk-vue
cd trfk-vue

Now let's scaffold Vue.js Vite-based project:

npm create [email protected]

It should look like this:

Scaffolding Vue project with Vite
Scaffolding Vue project with Vite

Now let's install modules:

cd frontend/
npm install

You can also temporarily start a development hot-reload server:

npm run dev

Now you should be able to open an app in browser and can play a little bit with adjusting it. You are now in development mode.

To build SPA into production-ready set of static files you should use:

npm run build

It will create a dist folder. But we will run this command in Docker at build time using correct layers caching.

Just create a Dockerfile in a frontend folder:

FROM node:16-alpine as builder
WORKDIR /code/
ADD package-lock.json .
ADD package.json .
RUN npm ci
ADD . .
RUN npm run build

FROM devforth/spa-to-http:latest
COPY --from=builder /code/dist/ .

At the beginning we specify that we need to pull lightweight alpine version of node image from Dockerhub to build our frontend. Then we add a package-lock and package files which are used by npm ci command. Only after that we are adding rest of the files and running build. After build done we pull another image which is our spa-to-http web server and extract dist folder generated on previous step.

⚠️ If you are not familiar with Docker layers caching it is extra important to emphasize that order of these commands in Dockerfile determines performance of your builds. Changes in package/lock are very rare because you are not installing/changing node modules every build. That is why we place them and npm ci command closer to the beginning of Dockerfile, so in majority of builds these files will not be changed and relatively slow npm ci command will not be re-executed.
🤔 Exciting fact, but a lot of pretty experienced coders whom I asked, thought that npm ci is acronym for Node Package Manager Continuous Integration. Well, you are pretty smart if you found this analogy by your guess too, but you would be smarter if you just Google it. It means Clean Install and nothing more. This command just completely removes node_modules folder (if it exists) and recreates it from repositories.

Now you can try to build image using

docker build .

If build is successful then you can spawn container from image by running:

docker run --rm -p 8080:8080 $(docker build -q .)

After that you can go to and test SPA. And it is already bundled in production more.

Traefik + compose example to route traffic into spa-to-http container

Now it is time to show you how simple Traefik configuration is. Return to trfk-vue folder and create a docker-compose.yml file there:

version: "3.3"


    image: "traefik:v2.7"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--api.dashboard=true" # Optional
      - "--api.insecure=true" # To enable Dashboard on http (for a local demo only, don't do in production)
labels: - "traefik.http.routers.dashboard.rule=Host(`trfk-dashboard.localhost`)" # You can also add fancy URL constraints here e.g. && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))
ports: - "80:80" - "8080:8080" # Optional, port used for traefik Dashboard and traefik API if you need it volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" trfk-vue: build: "frontend" labels: - "traefik.enable=true" - "traefik.http.routers.trfk-vue.rule=Host(`trfk-vue.localhost`)" - "" # port inside of trfk-vue which should be used

And start containers from our stack:

docker-compose up -d

Now you can go to http://trfk-vue.localhost/ and you should see your production ready SPA. 

Also you can use http://trfk-dashboard.localhost:8080/ to navigate dashboard:

Traefik Dashboard
Traefik Dashboard

In same way you can add an API as a container and route /api to it:

- "`trfk-vue.localhost`) && PathPrefix(`/api`)"

This container could be NodeJS Express, Python FastAPI, DjangoRestFramework or any technology you like to build your APIs.

Beautifully simple setup, is not it? We were able to set-up HTTP proxy routing only by one compose file🎉 If we would use Nginx we would have to do a lot of extra things:

  1. Create and manage another config file(s) for Nginx
  2. We would have to pass this config file(s) by attaching via volume or create a custom image inherited from Nginx image from Dockerhub
  3. We would have to deliver our dist folder via volume from frontend image into Nginx image. Now this folder remains in spa-to-http without extra-movements, no intermediate volumes are needed

Useful links