Summary
Setting NUM_WORKERS via docker-compose / environment has no effect when PRODUCTION_MODE=true. The confd template unconditionally renders the variable to an empty string, which entry.sh then loads back into the process environment, masking the user-supplied value. The API falls back to os.cpus().length workers regardless.
Reproduction
- Run open-balena with
PRODUCTION_MODE=true (the recommended setting for self-hosted deployments).
- In
docker-compose.yml, add to the api service:
environment:
NUM_WORKERS: "1"
- Recreate:
docker compose up -d api.
- Verify the env var is set on the container:
$ docker compose exec api env | grep NUM_WORKERS
NUM_WORKERS=1
- But check the master Node process's actual environ:
$ docker compose exec api sh -c 'cat /proc/$(pgrep -f "node.*index.js" | head -1)/environ | tr "\0" "\n" | grep NUM_WORKERS'
NUM_WORKERS=
- The API spawns one worker per vCPU instead of one.
Root cause
config/confd/templates/env.tmpl:
NUM_WORKERS={{if ne (getenv "PRODUCTION_MODE") "true"}}1{{end}}
In production mode this renders literally NUM_WORKERS= (empty). confd writes this to /usr/src/app/config/env, which entry.sh loads via load_env_file from the s6-overlay base, overwriting the container's original NUM_WORKERS=1.
In src/index.js:
parseInt(process.env.NUM_WORKERS ?? '0', 10) || (await import('os')).cpus().length
parseInt('') is NaN → falsy → falls through to CPU count.
For comparison, open-balena-vpn's template does honor user-supplied VPN_INSTANCE_COUNT in production mode — only the API has this bug.
Impact
Self-hosted deployments running small fleets cannot reduce the API memory footprint. On a 2-vCPU host the API uses ~3 GiB even at idle (two workers × ~1.5 GiB) when one worker would suffice for tens of devices. Workaround requires bind-mounting a patched template.
Suggested fix
Mirror the open-balena-vpn pattern — either honor the user-supplied value when set, or always pass it through:
NUM_WORKERS={{getenv "NUM_WORKERS"}}
This keeps the production default (empty → CPU count fallback in code) but lets operators override it.
Workaround
Bind-mount a patched env.tmpl in docker-compose.yml:
api:
environment:
NUM_WORKERS: "1"
volumes:
- ./api-env.tmpl:/usr/src/app/config/confd/templates/env.tmpl:ro
After applying, the API drops from ~3 GiB to ~1.1 GiB at idle.
Summary
Setting
NUM_WORKERSvia docker-compose / environment has no effect whenPRODUCTION_MODE=true. The confd template unconditionally renders the variable to an empty string, whichentry.shthen loads back into the process environment, masking the user-supplied value. The API falls back toos.cpus().lengthworkers regardless.Reproduction
PRODUCTION_MODE=true(the recommended setting for self-hosted deployments).docker-compose.yml, add to theapiservice:docker compose up -d api.Root cause
config/confd/templates/env.tmpl:In production mode this renders literally
NUM_WORKERS=(empty). confd writes this to/usr/src/app/config/env, whichentry.shloads viaload_env_filefrom thes6-overlaybase, overwriting the container's originalNUM_WORKERS=1.In
src/index.js:parseInt('')isNaN→ falsy → falls through to CPU count.For comparison,
open-balena-vpn's template does honor user-suppliedVPN_INSTANCE_COUNTin production mode — only the API has this bug.Impact
Self-hosted deployments running small fleets cannot reduce the API memory footprint. On a 2-vCPU host the API uses ~3 GiB even at idle (two workers × ~1.5 GiB) when one worker would suffice for tens of devices. Workaround requires bind-mounting a patched template.
Suggested fix
Mirror the
open-balena-vpnpattern — either honor the user-supplied value when set, or always pass it through:This keeps the production default (empty → CPU count fallback in code) but lets operators override it.
Workaround
Bind-mount a patched
env.tmplindocker-compose.yml:After applying, the API drops from ~3 GiB to ~1.1 GiB at idle.