TypeScript

Docker: Run a Shell Command Before ENTRYPOINT at Startup

Your envsubst step ran while the image was built, long before the container got its variables. A small entrypoint script runs it at start-up instead and still hands control to npm or node.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

TL;DR#

A RUN line executes while the image is built. The -e flag of docker run only exists when a container starts from the finished image, so a RUN envsubst < file1 > file2 step can never see it. Move the command into a small shell script, make that script the image's ENTRYPOINT, keep your start command in CMD, and finish the script with exec "$@" so Node takes over as PID 1. The pattern below was reproduced on Docker Engine 29.7.2 with the official node:22-bookworm-slim image (Node v22.23.3) on 15 October 2026.

The symptom: the variable is set, the file is still empty#

The Stack Overflow question builds a Node app with FROM node:boron, substitutes variables into a file with a RUN step, starts the container with -e, and finds no replacement. One detail first: the tool is envsubst (with a t), from GNU gettext. It is not in the official Node images. In the reproduction, command -v envsubst found nothing in node:22, node:22-bookworm-slim or node:22-alpine, so the Dockerfiles below install the Debian package gettext-base.

The broken shape, reduced to its essentials:

dockerfile
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends gettext-base && rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
COPY package.json server.js config.template.json ./
RUN envsubst < config.template.json > config.json
CMD ["node", "server.js"]

config.template.json holds { "apiUrl": "${API_URL}" } and server.js prints config.json at start-up. Run it with the value:

bash
docker run -d --name app -e API_URL=https://api.example.eu my-image
docker logs app
docker exec app printenv API_URL

The container has the variable, but the file was generated without it:

text
config.json = { "apiUrl": "" }
https://api.example.eu

Why the build step cannot see runtime variables#

The accepted answer starts with the whole explanation: "Images are immutable". The Dockerfile reference for RUN describes each RUN as executing its command to create a new layer on top of the current image. envsubst ran once, during docker build, when API_URL was unset; it wrote an empty string, and that config.json is now part of the image. docker run -e adds the variable to the environment of a container started from that image, and nothing re-runs the build steps.

So the command has to run at container start. Docker starts exactly one process per container, the ENTRYPOINT with the CMD appended to it, and that is the hook to use.

Fix: an entrypoint script that ends with exec "$@"#

  1. Write the script. It does the start-up work, then replaces itself with whatever command it was given:
sh
#!/bin/sh
set -e
envsubst < config.template.json > config.json
exec "$@"

set -e makes the container stop if the substitution fails, rather than starting the app with a broken file. "$@" expands to the script's arguments, which is where Docker puts the CMD. This is the same shape the Dockerfile best practices page shows for the Postgres official image.

  1. Copy it into the image, make it executable, and set it as the exec-form ENTRYPOINT, with the start command in CMD:
dockerfile
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends gettext-base && rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
COPY package.json server.js config.template.json ./
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "server.js"]

Both lines must be in exec form (JSON array). The ENTRYPOINT reference explains that docker run arguments are appended after an exec-form ENTRYPOINT, while the shell form ignores CMD and docker run arguments entirely, so "$@" would be empty.

This ENTRYPOINT also replaces the one the Node image ships. docker inspect shows ["docker-entrypoint.sh"] ["node"] on node:22-bookworm-slim and ["/usr/local/bin/entrypoint.sh"] ["node","server.js"] on the rebuilt image. For how a child image's ENTRYPOINT overrides a base image's, see overriding or removing an ENTRYPOINT inherited from a base image.

  1. Rebuild and run with the same -e as before:
bash
docker build -t my-image .
docker run -d --name app -e API_URL=https://api.example.eu my-image
docker logs app

Verify the fix#

With that single change (the substitution moved from RUN into the script), the log now shows the runtime value, and Node reports itself as PID 1:

text
config.json = { "apiUrl": "https://api.example.eu" }
node pid = 1

Then stop the container and read the log again. server.js installs a SIGTERM handler that prints a line and exits with code 0:

bash
docker stop app
docker logs app
docker inspect -f '{{.State.ExitCode}}' app

The last line of the log is the handler's, and the exit code that follows is 0:

text
SIGTERM received, exiting
0

That last line is the second thing to check, not just the file content.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

What exec changes: the shell stays PID 1 without it#

The negative control is the same script with the last line written as "$@" instead of exec "$@". The substitution still works, but the process tree is different. docker logs app shows the file filled in, but Node is no longer PID 1:

text
config.json = { "apiUrl": "https://api.example.eu" }
node pid = 7

Reading /proc/1/cmdline inside the container shows who holds PID 1 instead, the shell:

bash
docker exec app sh -c 'tr "\0" " " < /proc/1/cmdline; echo'
text
/bin/sh /usr/local/bin/entrypoint.sh node server.js

On docker stop, the docker stop reference says the main process gets SIGTERM and, after a grace period, SIGKILL. Here the main process is the shell. In the run, the "SIGTERM received" line never appeared and the exit code was 137: Node was killed rather than asked to stop, so any shutdown code (closing a database pool, finishing in-flight requests) never ran. The best practices page names the reason for exec: the final application becomes the container's PID 1, and "This allows the application to receive any Unix signals sent to the container."

CMD overrides keep the setup, --entrypoint skips it#

Because the script runs whatever it receives, a command typed after the image name still goes through the setup first:

bash
docker run --rm -e API_URL=https://api.example.eu my-image cat config.json
text
{ "apiUrl": "https://api.example.eu" }

Replacing the entrypoint itself bypasses the script, so the file is never generated:

bash
docker run --rm -e API_URL=https://api.example.eu --entrypoint cat my-image config.json
text
cat: config.json: No such file or directory

That is useful for debugging a broken script, and it explains why a run with a replaced entrypoint starts without the generated file.

Keeping npm start as the CMD#

The question's image ends with CMD [ "npm", "start" ]. With the same script and that CMD, PID 1 becomes npm start and Node runs as a child process (pid 20 in the run). On docker stop, the handler's line never appeared; npm printed npm error signal SIGTERM and the container exited with code 1, against 0 with CMD ["node", "server.js"]. If your app has shutdown logic, start it with node directly in CMD and keep npm start for local use.

The accepted answer also puts npm install in the entrypoint script and warns that it then runs on every start, adding latency. Dependencies belong in a RUN npm install at build time; only the steps that need runtime values belong in the script. If a bind mount later hides the node_modules the image installed, that is a different problem, covered in Cannot find module in Docker Compose.

When a build-time value is acceptable#

If the value is known at build time and not secret, the third answer's approach works: declare it with ENV before the RUN, or, as reproduced here, with ARG and --build-arg. With ARG API_URL and docker build --build-arg API_URL=https://build.example.eu, the file is filled during the build, and a later -e no longer changes it:

text
{ "apiUrl": "https://build.example.eu" }

One image then serves one environment only. The ARG reference adds a warning worth reading before choosing this path: "It isn't recommended to use build arguments for passing secrets such as user credentials, API tokens, etc." ENV does not hide a value either: the ENV reference says those values will persist when a container is run from the resulting image, and that docker inspect shows them.

If the container now refuses to start#

Two mistakes in the script file itself produce new errors right after this change. Saved with Windows line endings, the script fails before any of its lines run:

text
exec /usr/local/bin/entrypoint.sh: no such file or directory

Copied without the execute bit (reproduced with COPY --chmod=644 and no chmod), docker run fails with exit code 126 and a message ending in exec: "/usr/local/bin/entrypoint.sh": permission denied. Both, with their mechanisms and the exit codes Docker assigns, are covered in Docker 'OCI runtime create failed': convert the file to LF and keep the RUN chmod +x line. If the script runs but a native module then crashes while loading, the cause is usually a node_modules built on the host, explained in Exec format error loading a library.

The rule behind all of this is short: the Dockerfile decides what is in the image, the entrypoint script decides what happens each time a container starts. Put anything that reads docker run -e values in the script, end it with exec "$@", and the app keeps receiving the signals Docker sends it.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote