Loading...
Loading...
Marco Franssen

In this post, I want to show you a nice new feature in the Nginx 1.19 Docker image: configuration templates with environment variables. I requested it about two years ago, when I was trying to configure my static web applications more flexibly with different endpoints for backend microservices. Back then, I had my static pages fetch a JSON file containing the API endpoints. This way, I could simply mount a JSON file into my container with the endpoints for that particular deployment. It was a sort of service discovery mechanism: my React application fetched this JSON file as its first step.
With the release of the Nginx 1.19 Docker image, it is finally possible to do this using environment variables, which lets you use Nginx images in a more immutable fashion. You can package your application in a way that also works with the React development server proxy. In a React project, for example, you would define this in your package.json as follows.
{
"name": "my-react-app",
"proxy": "http://localhost:5000"
}This makes all calls to http://localhost:3000/api get proxied to http://localhost:5000, which is very convenient because you can use relative paths for all API calls (e.g. /api/todos). How awesome would it be if I could deploy my app like this in an immutable Docker container and only have to specify my API endpoint?
Good news! As of May 2020, you can do this.
We have a React application that fetches todo items from an API. During development, we use the following setup to reverse-proxy these calls to our API backend service.
| Endpoint | Service |
|---|---|
| http://localhost:3000 | React SPA |
| http://localhost:5000 | TODO API |
In our package.json, we configured this for our development workflow as follows.
{
"name": "my-react-app",
"proxy": "http://localhost:5000"
}When I deploy this app to production, I would love to configure the TODO API endpoint using the IP address or DNS name it gets in, for example, my Kubernetes cluster. Let's have a look at how we can achieve that by defining an Nginx template.
upstream todoapi {
server ${TODO_API};
}
server {
listen ${NGINX_PORT};
listen [::]:${NGINX_PORT};
server_name localhost;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 0;
gzip_types text/plain application/javascript text/css text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype;
root /usr/share/nginx/html;
location / {
index index.html index.htm;
expires -1;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://todoapi;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
location ~* \.(?:manifest|appcache|html?|xml|json)$ {
expires -1;
# access_log logs/static.log; # I don't usually include a static log
}
# Feed
location ~* \.(?:rss|atom)$ {
expires 1h;
add_header Cache-Control "public";
}
# Media: images, icons, video, audio, HTC
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
expires 1M;
access_log off;
add_header Cache-Control "public";
}
# CSS and Javascript
location ~* \.(?:css|js)$ {
expires 1y;
access_log off;
add_header Cache-Control "public";
}
}In the template above, I defined some caching rules for static assets and configured gzip compression. I also defined an upstream for reverse-proxying URLs that start with /api, and a variable for the port on which Nginx runs. Here is the relevant excerpt.
upstream todoapi {
server ${TODO_API};
}
server {
listen ${NGINX_PORT};
listen [::]:${NGINX_PORT};
location /api {
proxy_pass http://todoapi;
}
}As you can see, we can define variables using the syntax ${VAR}. When the Docker container starts, these variables are replaced with the values you supplied through environment variables.
Now that we have the template, we can define our Dockerfile.
FROM node:14-alpine as build
WORKDIR /app
COPY package*.json yarn.lock ./
RUN yarn install
COPY public public
COPY src src
RUN yarn build
FROM nginx:1.19-alpine
ENV NGINX_PORT 80
ENV TODO_API https://host.docker.internal:5000
COPY nginx/templates /etc/nginx/templates/
COPY --from=build /app/build /usr/share/nginx/htmlIn the Dockerfile, I use a multi-stage build to keep the Docker image as small as possible.
With the output of this intermediate build stage, we create our final image based on nginx:1.19-alpine.
We set sensible defaults for the two environment variables we defined in our default.conf.template.
host.docker.internal points to our Docker host (your Mac or Windows machine). With this default, I can run the image and connect it to the service running on my localhost, which makes debugging the API during development easier.
Then we copy our nginx/templates directory into the templates folder (you could use multiple templates and includes, for example).
Finally, we copy the output of yarn build from our intermediate build stage into the server root defined in our template.
With all of this in place, we can now build our Docker image.
docker build -t todo-app .By default, the Nginx entrypoint replaces variables found in /etc/nginx/templates/*.template with their respective environment values using envsubst. The results of this substitution are written to /etc/nginx/conf.d.
So make sure you copy the file into the templates folder and give it the extension template.
Now we can run the image as follows, using the defaults to connect to our API running on localhost via host.docker.internal.
docker run --rm -p 3000:80 todo-appIf you want to run everything in Docker Compose, you can use the following configuration. Here, we route API requests through the internal Docker network using the container's default DNS record.
version: "2.7"
services:
web:
image: todo-app
build: .
environment:
TODO_API: http://api:5000
ports:
- "3000:80"
api:
image: todo-api
build: .For a Kubernetes deployment, simply point the TODO_API variable to the Service deployed in front of your pods.
Now go ahead and try this for yourself. Feel free to comment below if you need help.
Thanks for reading this post. Please share it with your friends and colleagues.
Who doesn't want an immutable, configurable static web application?
Marco Franssen
Why I'm moving my blog from Hexo to Next.js, how I'm migrating Markdown posts and React components, and what's ready in the new blog preview.
Marco Franssen
Remove unwanted files from Git history with git-filter-repo, find previously deleted files, and push rewritten history using this practical walkthrough.
Marco Franssen
Build a two-node Elasticsearch cluster with Docker Compose, load-balance requests through Traefik, and add Cerebro as a web-based admin interface.
Marco Franssen
Use Lego and the ACME DNS-01 challenge to request a Let's Encrypt TLS certificate for local development without exposing your web server to the internet.