Skip to content

NUM_WORKERS environment variable is silently overridden in production mode #2263

Description

@HakanL

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

  1. Run open-balena with PRODUCTION_MODE=true (the recommended setting for self-hosted deployments).
  2. In docker-compose.yml, add to the api service:
    environment:
      NUM_WORKERS: "1"
  3. Recreate: docker compose up -d api.
  4. Verify the env var is set on the container:
    $ docker compose exec api env | grep NUM_WORKERS
    NUM_WORKERS=1
    
  5. 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=
    
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions