Deploy React/Vue/Angular in Docker simply and efficiently using Spa-to-http and Traefik
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 configuration eliminates tedious work and allows to turn development process into enjoyable and creative time.
In this post we will explain how to serve Single Page Applications without pain with a best performance. This will be done with our free and open-source server called Spa-to-http which we optimized specially for SPAs.
Motivation and story
Some time ago we started intensively use 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 smoothly ported a lot of existing projects to it. Traefik 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 rules.
You can also read comprehensive Nginx vs Traefik comparation.
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, most obvious used by many developers in community is placing Nginx after Traefik only to serve static.
So we decided why not to write our own lightweight Go-based image, dedicated for SPAs, absolutely simply configurable, compatible with most common setups. Most challenging part was a performance, everyone knows that Nginx is "ultra fast server", so even implementing same performance at first sight was not so simple. But we managed to make spa-to-http even faster the Nginx at some points, benchmarks described at the end of post. We open-sourced spa-to-http 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.
Spa-to-http vs Nginx for serving SPAs
Here is a brief comprasion
Spa-to-http | Nginx | |
Zero-configuration | ✅No config files, SPA serving works out of the box with most optimal settings | ❌Need to create a dedicated config file |
Ability to config settings like host, port, compression using Environment variables or CLI | ✅Yes | ❌No, only text config file |
Docker image size | ✅13.2 MiB (v1.0.3) | ❌142 MiB (v1.23.1) |
Brotli compression out-of-the-box | ✅Yes, just set env BROTLI=true | ❌You need a dedicated module like ngx_brotli |
What a most developers care about is serving performance, and we spent some extra-effort to optimize it too, here I will share results of benchmark performed at the bottom of post:
Spa-to-http | Nginx | |
Average time from container start to HTTP port availability (100 startups) | ✅1.358 (11.5% faster) | ❌1.514 |
Requests-per-second on 0.5 KiB HTML file at localhost * | ✅80497 (1.6% faster) | ❌79214 |
Transfer speed on 0.5 KiB HTML file * | ❌74.16 MiB/sec | ✅75.09 MiB/sec (1.3% faster) |
Requests-per-second on 5 KiB JS file at localhost * | ✅66126 (5.2% faster) | ❌62831 |
Transfer speed on 5 KiB HTML file * | ✅301.32 MiB/sec (4.5% faster) | ❌288.4 |
* wrk test results performed during 60 seconds
Example using spa-to-http
In example we will show how to deploy Vue/Svelte/React app using Vite bundler, but you can easily adopt it for any SPA Framework like Angular even if it is based on Webpack or CRA.
First, let's create a folder where we will work:
mkdir serve-spa
cd serve-spa
Now let's scaffold Vite-based project:
npm create vite@latest
You will be asked to type in project name, you can just use frontend. Then you can select any SPA framework you like with up/down arrows:
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 with a final bundle suitable for production serving. 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 source files and run 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 caching of Docker layers, we should 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 http://127.0.0.1:8080 and test SPA. It is already bundled in production more.
Traefik + compose example to route traffic into spa-to-http container
If you need to run other services like API on the same host you need route traffic using reverse proxy. Also you might use same proxy for example to accept SSL traffic to support and enforce https protocol. Here we will show how to do it with Traefik. Return to serve-spa folder and create a docker-compose.yml file there:
version: "3.3"
services:
traefik:
image: "traefik:v2.7"
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
spa-to-http:
build: "frontend"
labels:
- "traefik.enable=true"
- "traefik.http.routers.spa-to-http.rule=Host(`serve-spa.localhost`)"
- "traefik.http.services.spa-to-http.loadbalancer.server.port=8080" # port inside of spa-to-http which should be used
And start containers from our stack:
docker compose up -d
Now you can go to http://serve-spa.localhost/ and you should see your production ready SPA.
In same way you can add an API service as a container and route /api to it using label:
- "traefik.http.routers.my-api.rule=Host(`serve-spa.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 pure Nginx instead of Traefik we would have to do a lot of extra things:
- Create and manage another config file(s) for Nginx
- We would have to pass this config file(s) by attaching via volume or create a custom image inherited from Nginx image from Dockerhub
- 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
SPA-to-HTTP vs Nginx performance benchmark
Environment:
- 11th Gen Intel(R) Core(TM) i9-11900H @ 2.50GHz
- 64-bit, Ubuntu 20.04.3 LTS on WSL
- Docker Desktop, engine 20.10.11
To perform experiment we created Vue 3 project using:
vue create benchmark
And created Dockerfile
there, here is it content for Spa-to-http test:
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:1.0.3
COPY --from=builder /code/dist/ .
And the Dockerfile
for Nginx test:
FROM node:16-alpine as builder
WORKDIR /code/
ADD package-lock.json .
ADD package.json .
RUN npm ci
ADD . .
RUN npm run build
FROM nginx:1.23.1
COPY --from=builder /code/dist/ /dist/
ADD server.conf /etc/nginx/conf.d
And of-course Nginx requires config file, so here is my default, pure server.conf
:
server {
listen 8080;
root /dist;
index index.html;
try_files $uri /index.html; access_log off;
}
Before running tests we stopped all containers and run one dedicated container with a server:
docker run -p 8080:8080 --rm -it $(docker build -q .)
During each test we used wrk to profile code:
wrk -t20 -c1000 -d60s --latency http://127.0.0.1:8080/
Here is what we got:
We also did the same on larger file:
wrk -t20 -c1000 -d60s --latency http://127.0.0.1:8080/js/app.33fe7a9b.js
The result looks same:
You might probably achieve much better Nginx performance by doing tunings using tones of it's settings, but here we consider only default configs which could be used by average developer with a high probability.
To perform port availability test we used next bash script:
#!/bin/bash
docker kill $(docker ps -q)
docker rm $(docker ps -a -q)
docker rmi $(docker images -q)
docker build . -t availability_time_tester
experiments=''
for i in `seq 1 100`; do
docker run -p 8080:8080 --name availability_time_tester availability_time_tester &
start=`date +%s.%N`
while true; do
curl -s http://127.0.0.1:8080 > /dev/null
if [ $? -eq 0 ]; then
break
fi
done
docker kill availability_time_tester
docker rm availability_time_tester
end=`date +%s.%N`
dur=$( echo "$end - $start" | bc -l )
experiments=`echo $experiments $dur`
echo "Times $experiments"
done
Then received 100 values were averaged, here is results:
Average | Variance | Max | Min | |
Spa-to-http | 1.35 | 0.09 | 3.07 | 1.12 |
Nginx | 1.51 | 0.04 | 2.3 | 1.33 |
Conclusion
Spa-to-http could be integrated faster and easier into any web project, has 10 times smaller Docker image, has faster spawn time, and performs same or even faster then Nginx.