Skip to main content

Command Palette

Search for a command to run...

Dynamic base URLs in a Dockerized React app

Configuring per-tenant base URLs in a pre-built frontend docker image

Updated
7 min read
Dynamic base URLs in a Dockerized React app

The Problem

We were building a B2B application with a multi-tenant backend. Each tenant got their own Kubernetes deployment under their own subdomain tenant1.example.com, tenant2.example.com, and so on. Pricing plans, role and permission models, and feature sets varied. We also offered deployment flexibility: tenants could either run the app in-house or on our cloud.

The frontend (React, in our case) needed to sit on top of every tenant's backend. And here's where it got complicated.

The standard React + Docker setup expects the API base URL to live in a .env file, which gets baked into the Docker image at build time. Once the image is built, that URL is frozen and there's no changing it at runtime. The only way to point the frontend at a different backend is to build a new image with a different .env.

Which means: one Docker image per tenant. Every new client signed, every backend URL change, every staging vs production swap — a new build, a new image, a new deployment pipeline run.

That doesn't scale.

The build-time trap

How the URL gets baked in

We've established the core problem is that the base URL gets baked into the build. Here's a typical setup:

// .env

VITE_API_BASE_URL=https://tenant1.example.com/api/
// api-client.js

const api = axios.create({
	baseURL: import.meta.env.VITE_API_BASE_URL,
	timeout: 300000
})

This looks like the api-client.js is reading the variable at runtime, but it isn't. When we build the project with yarn build, the bundler (Vite, in our case) performs literal string replacement — everywhere it says import.meta.env.VITE_API_BASE_URL gets replaced by https://tenant1.example.com/api/. By the time the project runs inside the browser, there's no .env, just the hardcoded string in the JavaScript.

What this looks like in Docker

In light of this, when we try to make our Docker image support multiple base URLs using environment variables, it looks something like this:

# Dockerfile

# Before: one image per tenant. The base URL gets baked in at build time.
FROM node:20-alpine AS build
WORKDIR /app

COPY package.json yarn.lock ./
RUN corepack enable && yarn install --frozen-lockfile
COPY . .

# This bakes the URL into the build
ARG VITE_API_BASE_URL
# Promote build-arg to an env var so the bundler can read it
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN yarn build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

And the build commands hardcoded the tenant URL per build:

docker build \
  --build-arg VITE_API_BASE_URL=https://tenant1.example.com/api/ \
  -t my-app:tenant1 .

docker build \
  --build-arg VITE_API_BASE_URL=https://tenant2.example.com/api/ \
  -t my-app:tenant2 .

For every tenant, we have to make a new image — which is not ideal.

It's getting very clear that we need to get away from the environment variable, because no matter how we use it, the value ends up baked in. It's like hardcoding the string in code.

Why .env isn't the right tool here

Environment variables exist for a reason: keeping secrets out of source code. Database credentials, API tokens, and private keys belong in .env files because they shouldn't be visible to anyone reading the bundle.

But the base URL isn't a secret. It's right there in every network request the browser makes. Putting it in .env follows convention, but it doesn't add any real security. And it does add a real problem: the URL gets locked in at build time.

If we want one Docker image that works for every tenant, the URL needs to live somewhere the build doesn't see — somewhere the running container can write into the app at startup.

A new home for the base URL

What we actually need

Wherever we put our base URL, it needs to be globally available inside the app and injectable at runtime — which means it definitely can't be inside the repo anywhere.

For this very problem, browsers expose a global window object that represents the DOM unique to every tab. It's home to a variety of functions, namespaces, and constructors, but we can extend the object for our use.

Why not just use window.location.hostname?

An obvious question: among other things, window also exposes the hostname of the app via window.location.hostname. So why not use it as a base URL? The answer is that we can only do that if the domain of the app is always similar to the API base URL, which in our case isn't necessarily true — we also offer in-house deployment options, and the hostname solution makes things very limiting.

Frontend changes

Adding env.js

To wire up a new home for the base URL, changes need to happen in a few places.

First, remove the .env file and add an env.js file with the base URL in the public directory where assets live. It should look like this:

// public/env.js

window.API_BASE_URL = "https://demo.example.com/api/"

This is where the base URL lives from now on.

Loading env.js in index.html

To make it available to the app, attach this script file in the <head> tag of index.html:

<!-- index.html -->

<!doctype html>

<html lang="en">
  <head>
    <meta charset="UTF-8" />
	...
	<title>Lapis AI</title>
	<!-- Newly added connecting env.js -->
	<script type="text/javascript" src="./env.js"></script> 
  </head>
	
  <body>
	<div id="root"></div>
	<script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Teaching TypeScript about the new global

Since we're using TypeScript, the compiler won't know about window.API_BASE_URL and will complain. Extend the Window interface with a global declaration file:

// src/global.d.ts

declare global {
	interface Window {
		API_BASE_URL: string;
	}
}

export {};

Updating the API client

Finally, replace the code in the API client from import.meta.env.VITE_API_BASE_URL to window.API_BASE_URL to get the base URL:

// api-client.js

const api = axios.create({
	baseURL: window.API_BASE_URL,
	timeout: 300000
})

Verifying the frontend changes

One side of the task is done. Instead of expecting the base URL from a .env file, the app will pick it up from env.js. To test it, run the project and go to LOCALHOST/env.js — you should see the exact file in the browser.

Docker changes

The entrypoint script

Next is the second half of the implementation, where we'll make Docker aware of this change and set the URL at app startup rather than at build time.

Add an entry file at the root of the project — a bash script that takes the URL from the Docker command and writes it to env.js:

# docker-entrypoint.sh

#!/bin/sh


cat <<EOF > /usr/share/nginx/html/env.js
window.API_BASE_URL = "${API_BASE_URL}";
EOF

exec "$@"

This file edits env.js inside the Docker image and assigns the value from API_BASE_URL (defined in the Docker command) to window.API_BASE_URL at runtime.

The updated Dockerfile

A few changes in the Dockerfile make it run docker-entrypoint.sh at startup, so the base URL is ready before the app uses anything. Here's the final version:

# Dockerfile

FROM node:20-alpine AS build
WORKDIR /app

COPY package.json yarn.lock ./
RUN corepack enable && yarn install --frozen-lockfile

COPY . .
RUN yarn build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html

# Copy the entry file into the image, give permissions and set it as a startup file
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Running it

Building the image

Everything is wired up. It's time to build the project and test the app's build, which now supports dynamic injection of the URL.

To build the project, run:

docker build -t my-app .

Running per-tenant containers

And set the URL in the run command like this:


# For tenant 1
docker run -d -e \
API_BASE_URL=https://tenant1.example.com/api/ -p \
3001:80 my-app

# For tenant 2
docker run -d -e \
API_BASE_URL=https://tenant2.example.com/api/ -p \
3002:80 my-app

Two different containers of the same image (my-app) run with different base URLs for every tenant, set right in the command. You can also verify it in the Docker dashboard.

Verifying it works

Visit both URLs and go to the script file — you should see different API_BASE_URL values set for both localhosts.

And we are done 🎉 — One image for every tenant. That's the whole trick.