What problem are we trying to solve?
Say you have a JavaScript app that gets served up at http://mysite.com/js/myapp.js
. A typical performance optimization is to tell the browser to cache myapp.js
so that the user doesn't have to re-download the asset every time they use the app. If you practice continuous delivery, the problem you run into is delivering new app updates. If myapp.js
is cached, the user won't get the new updates until either a) they clear their cache or b) the max-age expires.
From the google dev docs:
Ideally, you should aim to cache as many responses as possible on the client for the longest possible period, and provide validation tokens for each response to enable efficient revalidation.
What we're going to do in this guide is we're going to come up with a way to cache our application assets for the longest possible time: FOREVER! Well sort of.. we are going to be using a hash based content caching strategy, which the google dev docs mentions it gives you the best of both worlds: client-side caching and quick updates.
Getting started with create-react-app
So to get started, we are going to use good ole create react app to quickly standup a new single page application.
Let's create a new app, create-react-app content-cache
So in a new directory, ~/code
, lets run this:
npx create-react-app content-cache
cd content-cache
So now you'll have a new app setup in ~/code/content-cache
and you should now be in the content-cache
directory.
Now we can run npm run build
which will output all the assets for your app in ./build
. With these assets now available, let's take a look at serving these with nginx.
nginx + docker = yayyyyyy
Let's go ahead and create a new file, touch ~/code/content-cache/Dockerfile
with the following contents:
FROM nginx:1.13-alpine
RUN apk add --no-cache bash curl
COPY nginx/ /
CMD ["/docker-entrypoint.sh", "nginx", "-g", "daemon off;"]
EXPOSE 8080
COPY build/static/ /usr/share/nginx/html/
COPY package.json /
You'll notice we are missing a few things:
- The
nginx/
folder being copied. - The
docker-entrypoint.sh
script.
Let's go ahead and add those now.
Create a new directory, mkdir -p ~/code/content-cache/nginx/etc/nginx
and then create a new file touch ~/code/content-cache/nginx/etc/nginx/nginx.conf
.
Then open up the file and copy the following contents into it:
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain application/xml application/javascript text/css;
include /etc/nginx/conf.d/*.conf;
}
Most of this is boilerplate nginx config, so I am not going to spend time explaining it, you can learn more from the nginx docs. Just note that we are including /etc/nginx/conf.d/*.conf
, which includes the default.conf
file, we'll be creating next.
Let's go ahead and create the file, touch ~/code/content-cache/nginx/etc/nginx/conf.d/default.conf
and add the following contents to it:
server {
listen 8080;
# include the hash based content
include /etc/nginx/conf.d/app/*.conf;
location ~ ^/$ {
# we are serving the app at `/a/`
return 303 a/;
}
# serve other static assets
location / {
root /usr/share/nginx/html;
index /index.html;
try_files $uri /index.html;
include /etc/nginx/conf.d/app/preload.headers;
}
}
We are going to be serving the app at /a/
, which is a strategy used to make life a bit easier when dealing with reverse proxying to backend APIs that live on the same domain.
So again, make note that we are including /etc/nginx/conf.d/app/*.conf;
, which is our hash based content.
Now let's move on to creating a new file touch ~/code/content-cache/nginx/docker-entrypoint.sh
where the magic happens.
Paste in the following contents:
#!/usr/bin/env bash
mkdir -p /etc/nginx/conf.d/app
pushd /usr/share/nginx/html/js/ > /dev/null
APP_JS=/app/js/app.js
for js in main.*.*.js
do
cat > /etc/nginx/conf.d/app/js.conf <<EOF
location ~* ^/app/js/main.js([.]map)?\$ {
expires off;
add_header Cache-Control "no-cache";
return 303 ${js}\$1;
}
location ~* ^/app/js/(main[.][a-z0-9][a-z0-9]*[.]js(?:[.]map)?)\$ {
alias /usr/share/nginx/html/js/\$1;
expires max;
add_header Cache-Control "public; immutable";
}
EOF
APP_JS="/js/${js}"
break;
done
RUNTIME_JS=/app/js/runtime.js
for js in runtime~main.*.js
do
cat > /etc/nginx/conf.d/app/js.conf <<EOF
location ~* ^/app/js/runtime~main.js([.]map)?\$ {
expires off;
add_header Cache-Control "no-cache";
return 303 ${js}\$1;
}
location ~* ^/app/js/(runtime~main[.][a-z0-9][a-z0-9]*[.]js(?:[.]map)?)\$ {
alias /usr/share/nginx/html/js/\$1;
expires max;
add_header Cache-Control "public; immutable";
}
EOF
RUNTIME_JS="/js/${js}"
break;
done
VENDOR_JS=/app/js/vendor.js
for js in 2.*.*.js
do
cat >> /etc/nginx/conf.d/app/js.conf <<EOF
location ~* ^/app/js/2[.]js([.]map)?\$ {
expires off;
add_header Cache-Control "no-cache";
return 303 ${js}\$1;
}
location ~* ^/app/js/(2[.][a-z0-9][a-z0-9]*[.]js(?:[.]map)?)\$ {
alias /usr/share/nginx/html/js/\$1;
expires max;
add_header Cache-Control "public; immutable";
}
EOF
VENDOR_JS="/js/${js}"
break;
done
cd ../css
APP_CSS=/app/css/main.css
for css in main.*.*.css
do
cat > /etc/nginx/conf.d/app/css.conf <<EOF
location ~* ^/app/css/main.css([.]map)?\$ {
expires off;
add_header Cache-Control "no-cache";
return 303 ${css}\$1;
}
location ~* ^/app/css/(main[.][a-z0-9][a-z0-9]*[.]css(?:[.]map)?)\$ {
alias /usr/share/nginx/html/css/\$1;
expires max;
add_header Cache-Control "public; immutable";
}
EOF
APP_CSS="/css/${css}"
done
cd ..
cat > /etc/nginx/conf.d/app/preload.headers <<EOF
add_header Cache-Control "public; must-revalidate";
add_header Link "<${APP_CSS}>; rel=preload; as=style; type=text/css; nopush";
add_header Link "<${VENDOR_JS}>; rel=preload; as=script; type=text/javascript; nopush";
add_header Link "<${APP_JS}>; rel=preload; as=script; type=text/javascript; nopush";
add_header X-Frame-Options "SAMEORIGIN" always;
EOF
cat > index.html <<EOF
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
<title>Create React app</title>
<link href="${APP_CSS}" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="${VENDOR_JS}"></script>
<script type="text/javascript" src="${APP_JS}"></script>
<script type="text/javascript" src="${RUNTIME_JS}"></script>
</body>
</html>
EOF
popd > /dev/null
exec "$@"
Let's go ahead and break this down bit by bit.
mkdir -p /etc/nginx/conf.d/app
pushd /usr/share/nginx/html/js/ > /dev/null
This creates a new directory and uses pushd
to cd into the /usr/share/nginx/html/js
directory, while redirecting the output to /dev/null
so the console doesn't get noisy.
APP_JS=/a/js/app.js
for js in main.*.*.js
do
cat > /etc/nginx/conf.d/app/js.conf <<EOF
This is a for loop, which iterates over the javascript files matching main.*.*.js
, which is the pattern for our hashed content files. It then concatenates the location blocks into a file /etc/nginx/conf.d/app/js.conf
.
location ~* ^/a/js/main.js([.]map)?\$ {
expires off;
add_header Cache-Control "no-cache";
return 303 ${js}\$1;
}
We also are redirecting any requests to /a/js/main.js
to the matching hash based filed.
location ~* ^/a/js/(main[.][a-z0-9][a-z0-9]*[.]js(?:[.]map)?)\$ {
Also notice we are matching .map
files so that we can load source map files as well.
alias /usr/share/nginx/html/js/\$1;
Then we are caching those hash based files to the MAX!
expires max;
add_header Cache-Control "public; immutable";
}
EOF
We then store the hashed asset file in APP_JS
so we can use that later in the script.
APP_JS="/js/${js}"
break;
done
The next three for loops do the same as above, but for the different asset files. The runtime files runtime~main.*.js
, the vendor files 2.*.*.js
, and the css files main.*.*.css
.
Next we set our preload.headers
.
cat > /etc/nginx/conf.d/app/preload.headers <<EOF
add_header Cache-Control "public; must-revalidate";
add_header Link "<${APP_CSS}>; rel=preload; as=style; type=text/css; nopush";
add_header Link "<${VENDOR_JS}>; rel=preload; as=script; type=text/javascript; nopush";
add_header Link "<${APP_JS}>; rel=preload; as=script; type=text/javascript; nopush";
add_header X-Frame-Options "SAMEORIGIN" always;
EOF
This tells the browser to preload these assets and store these files in the http cache. We specify nopush
so that the server knows we only want to preload it for now.
We then dynamically create our index.html
file:
cat > index.html <<EOF
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
<title>Create React app</title>
<link href="${APP_CSS}" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="${VENDOR_JS}"></script>
We use the APP_JS
variable to set the src for our js file. We also do the same for the other asset files.
<script type="text/javascript" src="${APP_JS}"></script>
<script type="text/javascript" src="${RUNTIME_JS}"></script>
</body>
</html>
EOF
Then we change back to the original directory with popd > /dev/null
and then execute any args passed to this script exec "$@"
. That's important otherwise the args after the "/docker-entrypoint.sh"
will not work in our Dockerfile command: CMD ["/docker-entrypoint.sh", "nginx", "-g", "daemon off;"]
.
Let's see it all in action
We're going to build and run the Docker container.
In ~/code/content-cache
, run:
-
chmod +x ./nginx/docker-entrypoint.sh
- make the script executable. -
docker build -t nginx/test .
- this builds the image. -
docker run --name="nginx-test-app" -p 8080:8080 nginx/test
- this runs the docker container.
Now that your app is running, head to http://localhost:8080. Open up the network tab in your dev tools and refresh the page. You should see the JavaScript and CSS assets should now be getting cached. It should look something like this:
Looking good! Now let's do another build just to make sure it is working as intended. Kill the current docker container by pressing ctr + c and then running docker rm nginx-test-app
.
Now run npm run build && docker build -t nginx/test .
then docker run --name="nginx-test-app" -p 8080:8080 nginx/test
, open up http://localhost:8080 and checkout the network tab to confirm that the asset files are from the latest build.
🤘Now we're talking! At this point now, we have the best of both worlds setup: Max content caching and quick updates when a new version of our app is deployed.
Feel free to use this technique and modify to fit your own needs. The link to the repo is below.
Resources:
Credits:
- @connolly_s - showed me the light with this strategy 🤣
Top comments (0)