future legacy code

How to actually configure front end environment variables

Published on

7 min read

Authors

Hello friends.

Deploying frontend applications these days is relatively straight-forward. All the major frameworks provide some variation on npm run build that packages your application ready to be deployed to your web host of choice.

Most frameworks also give you a way to use environment variables with something like process.env.YOUR_VARIABLE and using a .env file to configure these variables locally. This makes for a great local development experience, but what about when you need to deploy your application to a dev/test/production environment? At this point, the recommendation is generally to use a .env.production file which will substitutes to process.env.YOUR_VARIABLE values at build time.

This is where things get messy.

There are a couple of issues here, but the main one is that in order to deploy your application to multiple environments, you will need to build it multiple times, using a different .env file for each.

Ew David

At this point there are 3 options:

  1. Build application for each environment
  2. Hand roll something else - e.g. config.js or get config via an API
  3. Use tokens in .env.production that can be substituted with environment specific variables at runtime

Option 1 is not great as it means we have to cop whatever our build time is each time we want to deploy the application to a new environment.

Option 2 is fine and it is something I've done in the past. The main problem is that it is another 'thing' that your application has to do on each new site visitor before it can start being usable.

I like option 3 as it lets us keep the nicer developer experience of .env files and we can quickly deploy to new environments or change config settings without having to rebuild the app.

The process works like this:

  1. Create an .env file for local development - e.g.
API_URL=https://localhost:7443
  1. Create .env.production file with tokens for each variable - e.g.
API_URL=#{API_URL}
  1. Build your application with npm run build (or the appropriate command for the framework you're using)
  2. Push the produced package somewhere appropriate (e.g. Azure DevOps Artifact, Docker Registry, etc.)

Then whenever you want to deploy the app:

  1. Pull down the package
  2. Substitute the tokens for environment specific variables
  3. Deploy the package

The way you substitute the tokens depends on the tool you're using:

For AzureDevops pipelines, there is Replace Tokens

For GitHub actions, there is Replace tokens

For Docker, you'll need to set your environment variables then execute an entrypoint.sh script that does it... like this:

#!/bin/sh

# Directory to process
target_directory="/app"

# Function to replace tokens in a file
replace_tokens() {
    local file="$1"
    local content_before=$(cat "$file")
    local content=$content_before
    local regex='#{[A-Za-z_][A-Za-z0-9_]*}'

    while echo "$content" | grep -q "$regex"; do
        local token="$(echo "$content" | grep -o "$regex" | head -n 1)"
        local var_name="$(echo "$token" | cut -c 3- | rev | cut -c 2- | rev)"
        local var_value
        eval "var_value=\$$var_name"

        if [ -n "$var_value" ]; then
            # Use a custom delimiter and escape it
            local delimiter="|"
            local token_escaped="$(echo "$token" | sed 's/[\&/]/\\&/g')"
            local var_value_escaped="$(echo "$var_value" | sed 's/[\&/]/\\&/g')"

            echo "Replacing token '$token' with '$var_value' in $file"
            content="$(echo "$content" | sed "s${delimiter}${token_escaped}${delimiter}${var_value_escaped}${delimiter}")"
        else
            echo "Environment variable '$var_name' not found for token '$token' in $file (Removing)"
            content="$(echo "$content" | sed "s/$token//")"
        fi
    done

    if [ "$content" != "$content_before" ]; then
        echo "Saving $file"
        echo -n "$content" > "$file"
        echo ''
    fi
}

# Use find with null-terminated output and a while read loop
find "$target_directory" -type f -print0 | while IFS= read -r -d '' file; do
    replace_tokens "$file"
done

echo 'Token variable substitution complete'

exec "$@"

Your Dockerfile then needs to call the entrypoint.sh file on startup: e.g. ENTRYPOINT ["/app/entrypoint.sh"]

Here's a complete Dockerfile example for Nextjs:

FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat

WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci


# Rebuild the source code only when needed
FROM base AS builder

WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

# Production image, copy all the files and run next
FROM base AS runner

WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh

USER nextjs

EXPOSE 3000

ENV PORT 3000

# set hostname to localhost
ENV HOSTNAME "0.0.0.0"

ENTRYPOINT ["/app/entrypoint.sh"]

CMD ["node", "server.js"]

Happy deployments!