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.
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:
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:
docker run -d --name app -e API_URL=https://api.example.eu my-image
docker logs app
docker exec app printenv API_URLThe container has the variable, but the file was generated without it:
config.json = { "apiUrl": "" }
https://api.example.euWhy 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 "$@"#
- Write the script. It does the start-up work, then replaces itself with whatever command it was given:
#!/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.
- Copy it into the image, make it executable, and set it as the exec-form
ENTRYPOINT, with the start command inCMD:
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.
- Rebuild and run with the same
-eas before:
docker build -t my-image .
docker run -d --name app -e API_URL=https://api.example.eu my-image
docker logs appVerify 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:
config.json = { "apiUrl": "https://api.example.eu" }
node pid = 1Then stop the container and read the log again. server.js installs a SIGTERM handler that prints a line and exits with code 0:
docker stop app
docker logs app
docker inspect -f '{{.State.ExitCode}}' appThe last line of the log is the handler's, and the exit code that follows is 0:
SIGTERM received, exiting
0That 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:
config.json = { "apiUrl": "https://api.example.eu" }
node pid = 7Reading /proc/1/cmdline inside the container shows who holds PID 1 instead, the shell:
docker exec app sh -c 'tr "\0" " " < /proc/1/cmdline; echo'/bin/sh /usr/local/bin/entrypoint.sh node server.jsOn 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:
docker run --rm -e API_URL=https://api.example.eu my-image cat config.json{ "apiUrl": "https://api.example.eu" }Replacing the entrypoint itself bypasses the script, so the file is never generated:
docker run --rm -e API_URL=https://api.example.eu --entrypoint cat my-image config.jsoncat: config.json: No such file or directoryThat 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:
{ "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:
exec /usr/local/bin/entrypoint.sh: no such file or directoryCopied 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
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix Docker npm EBADENGINE: Unsupported engine
Read the Required and Actual lines, distinguish npm self-upgrades from application installs, and stop a floating npm release breaking an unchanged Dockerfile.
Fix esbuild spawnSync ETXTBSY in Docker: Node 20.3 io_uring
Why npm install of esbuild started failing with ETXTBSY on node:20.3 images in June 2023, what changed in libuv and Docker since, and the build fix that holds today.
Fix curl getaddrinfo() thread failed to start in Docker
Why curl fails to resolve a host in a RUN step on one server and not on your laptop, how to prove it is the engine and not the image, and which fix to pick.


