If you're doing frontend web developing you know you have to keep some aspect of your app configurable. The most obvious case of this configuration is the API base url. You should define it in a way you could pass it as an environment variable in the build process.
When you're building with docker, it means this becomes a build argument, as opposed to container argument.
This is not ideal. Because then you should have a separate and different docker image for each environment (say canary, staging and production).
I'll be sharing with you a solution i came up with to solve this in all of our frontend projects. I'll be using an Angular project to illustrate.
Before
The first solution i had is to include a script to create the specific environment.something.ts file before the build process.
Here is how it looks like:
// FILE: front/scripts/set-env.ts
import { writeFile } from 'fs';
import { argv } from 'yargs';
// This is good for local dev environments, when it's better to
// store a projects environment variables in a .gitignore'd file
require('dotenv').config();
// Would be passed to script like this:
// `ts-node set-env.ts --environment=dev`
// we get it from yargs's argv object
const environment = argv.environment;
const isProd = environment === 'prod';
const targetPath = environment === 'dev'
? `./src/environments/environment.ts`
: `./src/environments/environment.${environment}.ts`;
const envConfigFile = `
export const environment = {
production: ${isProd},
apiBaseUrl: '${process.env.API_BASE_URL}',
version: 'v${require('../package.json').version}'
};
`;
writeFile(targetPath, envConfigFile, function (err) {
if (err) {
console.log(err);
}
console.log(`Output generated at ${targetPath}`);
});
And i run the script in the docker build process as the following:
# FILE: Dockerfile
### STAGE 1: Build ###
# We label our stage as 'builder'
FROM node:10-alpine as builder
ARG NODE_ENV
ARG API_BASE_URL
ENV NODE_ENV "$NODE_ENV"
ENV API_BASE_URL "$API_BASE_URL"
COPY package.json package-lock.json ./
RUN npm set progress=false && npm config set depth 0 && npm cache clean --force
RUN npm install -g ts-node yargs dotenv typescript@2.4.2
## Storing node modules on a separate layer will prevent unnecessary npm installs at each build
RUN npm i && mkdir /ng-app && cp -R ./node_modules ./ng-app
WORKDIR /ng-app
COPY . .
## Build the angular app in production mode and store the artifacts in dist folder
RUN ts-node ./scripts/set-env.ts --environment=prod #actually this is defined as a script in package.json, let's add it here so things would make sense.
RUN npm run build
### STAGE 2: Setup ###
FROM nginx:1.13.3-alpine
## Copy our default nginx config
COPY nginx/default.conf /etc/nginx/conf.d/
## Remove default nginx website
RUN rm -rf /usr/share/nginx/html/*
## From 'builder' stage copy over the artifacts in dist folder to default nginx public folder
COPY --from=builder /ng-app/dist /usr/share/nginx/html
CMD ["nginx", "-g", "daemon off;"]
In the end, we end up with a very small docker file, containing nginx and only the build artifacts. No node_modules, no nodejs, nothing. This is good until you want to use the same image for multiple environments.
The first idea to come to mind is to ditch this 2 step build process and just ship the actual angular code and serve it on the fly using any runtime configuration. Right?
The only problem is, the docker image would get 200 times bigger, and this is a big no no.
Solution!
Let's use some Linux skills and figure out how we could change the API base url in the compiled Javascript files!
First, that old script becomes like this:
// FILE: front/scripts/set-env.ts
...
export const environment = {
production: ${isProd},
apiBaseUrl: 'API_BASE_URL',
version: 'v${require('../package.json').version}'
};
`;
...
```
Basically, we are passing this string `'API_BASE_URL'` in the configuration.
Next, we need to find and replace that string in every compiled Javascript file in the resulting image. Let's make a small adjustment in the Dockerfile:
```Dockerfile
# FILE: Dockerfile
...
COPY --from=builder /ng-app/dist /usr/share/nginx/html
COPY --from=builder /ng-app/scripts/replace_api_url.sh /
CMD ["sh", "replace_api_url.sh"]
```
We did 2 things here:
* added a new script called `replace_api_url.sh` which we'll see in a minute
* and replaced the command line so it would no longer launch nginx directly, instead, we want to execute our newly added script.
Finally, this is what the new script looks like:
```typescript
// FILE: front/scripts/replace_api_url.sh
#!/usr/bin/env sh
find '/usr/share/nginx/html' -name '*.js' -exec sed -i -e 's,API_BASE_URL,'"$API_BASE_URL"',g' {} \;
nginx -g "daemon off;"
```
So we're launching nginx at the end, but we're expecting an environment variable *at runtime* to replace every occurrence of `API_BASE_URL` in all js files.
Now you only need to pass any API url when running a new docker contanaier!
```bash
docker run -p 3000:3000 -e API_BASE_URL=http://myawesomebackend.com/api front
```
Thats's it!
Thank you for reading, this is my first post here and would like to have your feedback so i can continue writing better and better stuff here.
![THE END](https://www.mememaker.net/api/bucket?path=static/img/memes/full/2015/Mar/3/2/you-made-it-to-the-end.jpg)
_Credit: Cover image from https://medium.com/developer-diary/quick-start-guide-for-docker-with-angular-on-windows-492263edeaf8_
Top comments (4)
Hey Jacer, nice article. I have a problem running the script:
$ docker run -p 3003:80 -e REACT_APP_API_URL=hondje 07afdda70bba
: not found_url.sh: 2: replace_api_url.sh:
find: missing argument to `-exec'
I copied your sh script and changed only the variable:
!/usr/bin/env sh
find '/usr/share/nginx/html' -name '*.js' -exec sed -i -e 's,REACT_APP_API_URL,'"$REACT_APP_API_URL"',g' {} \;
nginx -g "daemon off;"
I'm not great with shell so could you help with this?
hey, I got it to work when I replace the '\' with a '+'
Thanks for sharing a very clever pattern that just works. I used a slightly modified version where instead of rewriting all js output files, I only modify the
config.json
file that contains the base url of my api service.Also, one issue I ran into by following your example, was docker not being able to find the
replace_api_url.sh
file. The fix was prefixing the name with a forward slash as following:CMD ["sh", "/replace_api_base.sh"]
Nice one. Solved my design problem.