Skip to content

Production Setup (Admin + Workers) ​

For production, Fluxify splits into two roles so you can scale request handling without touching the control plane:

RoleImageResponsibility
Adminfluxify-adminControl plane — dashboard, admin API, AI gateway. Owns the database and prepares your routes for the workers. Run one.
Orchestratorfluxify-orchestratorStarts and stops the worker containers for you. Holds the Docker socket; runs none of your code. Run one (a second one stands by).
Workerfluxify-workerServes your published API. Holds no database connection. Run many — the orchestrator creates them.

All three are published to the GitHub Container Registry under ghcr.io/fluxify-rest/.

An edge proxy (Traefik) sits in front and sends admin traffic to the admin container and everything else to the workers.

TIP

This guide runs the workers on one Docker host. To run them on a Kubernetes cluster instead, see Install on Kubernetes.

Which tag to pull ​

Every release tags all three images together, so admin, orchestrator and worker always come from the same build. Never mix versions between them.

TagWhat it is
v0.1.0-alpha.1One exact release. Pin this for anything you care about.
alphaMoves to the newest pre-release. Convenient while Fluxify is pre-1.0.
latestThe newest stable release. Does not exist yet — the first 1.0 release creates it.

WARNING

Fluxify is pre-1.0, so every release is a pre-release and latest is not published. Use alpha, or a pinned version tag.

TIP

Just evaluating Fluxify or running it on a single machine? The all-in-one Kit image is simpler. Come back here when you need to scale.


Why Traefik here? ​

The production stack runs multiple worker containers and load-balances across them. Traefik discovers each worker automatically from its Docker labels and spreads traffic across every replica with no manual list to maintain — add or remove workers and routing updates itself. The Kit image uses a simpler built-in proxy because it only ever has one of each service.


Who starts the workers ​

You don't. There is no worker service in the compose file — the orchestrator creates worker containers, and compose runs only the infrastructure around them.

The reason is that hand-writing a worker service is easy to get wrong in a way nothing tells you about: its settings are a project id, a mode and a list of group ids, and a misplaced one produces a worker that starts happily and serves the wrong things. So you describe what you want instead — "this project wants a workflow node for these groups, two of them" — and the orchestrator creates the containers, labels them for Traefik, and removes them when you stop asking for them.

That request is called a claim. A fresh stack seeds one automatically: a single node serving every project and doing both jobs, which is exactly what the old worker service was. Set ORCHESTRATOR_SEED_DEFAULT_CLAIM=false in your .env to start with no workers at all.

Ownership is split cleanly, because two things managing the same container will fight over it:

  • compose owns nats, postgres, valkey, traefik, admin, orchestrator
  • the orchestrator owns every worker

WARNING

docker compose ... --remove-orphans deletes the orchestrator's workers. Compose has no idea they were never its containers. They come back on the next reconcile pass (about five seconds) as long as the orchestrator is running.

Two other things worth knowing:

  • A license that lapses does not take your traffic down. Running workers keep running; only a restart is refused. Fix the licence and nothing had to fail in the meantime.
  • Killing the orchestrator does not stop your API. Workers are not its children — they are separate containers with Docker's own restart policy. You can restart or upgrade the control plane while traffic keeps being served.

For how any of that actually works — the leader lease, the reconcile loop, what forces a container to be replaced — see The Orchestrator.


Architecture ​

Only the admin container connects to PostgreSQL. Workers receive your routes ready to run over NATS — see Request Lifecycle.

Workers only receive traffic once they report ready. Traefik health-checks each replica and holds traffic back until its routes have loaded.


One worker group per project ​

This is the main thing to plan for. A worker serves exactly one project.

Copies of the same worker service share their settings, so they always share a project. That means:

  • More traffic for a project → more replicas of that project's worker service.
  • Another project → another worker service, with its own project id.

The bundled compose file ships two worker services as a worked example — worker-project-a and worker-project-b, two replicas each. Copy the pattern to add a third.

Because your routes own the whole URL path space, projects are told apart by hostname, not by path prefix. The example uses project-a.localhost and project-b.localhost; point real hostnames at the stack for production.


What a worker runs ​

By default a worker does everything: it serves your project's HTTP routes and it runs that project's background workflows. That is the right setup for most deployments, and it needs no configuration.

Set WORKER_MODE when you want to separate the two:

WORKER_MODEServes routesRuns workflows
both (default)yesyes
routeyesno
workflownoyes

Splitting them is worth doing when background work is heavy enough to compete with traffic: give workflows their own worker service and the request workers stop sharing a CPU budget with a five-minute report build. It is the same worker image either way — only the setting differs.

IMPORTANT

All workers for one project must use the same mode. A worker that finds another mode already running for its project refuses to start and says so, rather than joining in and running every workflow twice. To split a project, change every worker of that project at once.

An unrecognised value is rejected at boot instead of being treated as the default — a typo should not silently change what a machine runs.

Running triggers on their own workers ​

Triggers belong to groups. Set WORKER_GROUP_ID to a group's id and that worker runs only the triggers in that group. Leave it unset and the worker runs every group — the default.

This is how a busy trigger gets machines of its own: put it in a group, and point a separate worker service at that group. Routes and queued jobs are not affected by this setting; it only decides which triggers a worker listens to.

NOTE

Moving a trigger to another group moves it between workers: the old worker stops reading it and the new one starts.


Experimental CPU-stall protection ​

Compiled workers normally run with no execution-watchdog overhead. To enable CPU-stall containment for one project, save this project setting through the admin API:

text
experimental.workerTimeouts.enabled = true

The setting is published through NATS and takes effect without redeploying or restarting the container. When enabled, each route's timeoutSeconds defaults to 30 seconds and may be increased. A route is terminated only when its event loop is blocked past that budget; long asynchronous work remains running while the execution process continues to heartbeat. The supervisor then replaces only the isolated execution process, so the container and its NATS watcher stay up.


Request body size ​

Every request body a route receives is capped. The default is 8 MB, and a request above it is rejected with 413 before any workflow runs.

env
# Kilobytes. 8192 = 8 MB (the default).
WORKER_MAX_STREAM_SIZE=8192

Set it in the same .env the workers read (see Step 1), or per service in your compose file. Raise it deliberately: the whole body is held in memory while the route runs, so the cap multiplied by your concurrency is memory the worker has to have.

Fluxify is an API server, not an upload service

Routing large files through your API means the upload is paid for twice — once into the worker, once out of it — and every megabyte competes with the requests you actually want to serve.

For file uploads, have your client upload directly to object storage with a pre-signed URL: your route issues a short-lived signed S3 (or compatible) URL, the client PUTs the file straight to storage, then calls your API again with the resulting object key. Only the key ever passes through Fluxify.


Scheduled run limit ​

A Trigger Workflow block can hold a run for later. Each held run is kept by NATS until its time comes, so how far ahead a run may be scheduled is a deployment decision. The default is 30 days; a block asking for a later time fails with an error.

env
# How far ahead a run may be scheduled. Hours, minutes or seconds: 720h = 30 days (the default).
WORKER_SCHEDULE_MAX_HORIZON=720h

Write it as s, m and h — there is no day unit, so a year is 8760h. A value that cannot be read stops the worker at startup. Set it on the workers, in the same .env they read (see Step 1).

There is no cap on how many runs may be waiting. Each one is a small message on your NATS server, stored for as long as it waits. If you allow a long limit on a busy system, size your NATS storage for it: the number of runs you expect to be waiting at once, times the size of the data each one carries.


Local async executor ​

Compiled workers include a bounded local executor for the future async-trigger and workflow runtime. It is currently an internal capability rather than a route setting or public scheduling API. It queues only I/O-oriented detached work in the same execution process; it does not isolate CPU-heavy work.

Configure its per-worker bounds through environment variables:

env
# Defaults shown. A full executor returns 429 for a new async submission.
ASYNC_EXECUTOR_MAX_IN_FLIGHT=10
ASYNC_EXECUTOR_MAX_QUEUE_DEPTH=100
# Graceful shutdown waits this long for accepted work before the process exits.
ASYNC_EXECUTOR_DRAIN_TIMEOUT_MS=30000

Future route-to-route, webhook, cron and message-bus adapters use this same submission boundary. Distributed workflows will use durable JetStream scheduling instead of relying on the process-local queue.

Admin API rate limit ​

The admin API — everything the portal calls, plus login and user management — is capped per signed-in user. The default is 15 requests per second. A user over the limit gets a 429 with a Retry-After: 1 header, and the very next second they are served normally again. The portal waits and retries a 429 for you (up to 3 times), so a short burst does not show an error.

env
# Admin API requests allowed per second, per user. 0 turns the limit off.
ADMIN_RATE_LIMIT_PER_SEC=15

Set it on the admin service, in the same .env it reads (see Step 1). Leaving it unset gives you the default, so an existing deployment needs no change.

This is a guard rail for the control plane, not a traffic policy for your projects:

  • Your API routes are not affected. Only the admin surface is counted; requests to the routes you build are never capped by this setting.
  • It is counted per signed-in user, so one busy account cannot slow the admin API down for your teammates.
  • Signed-out requests are not counted. Behind a reverse proxy every signed-out visitor looks like the same caller, so counting them would mean one visitor could lock out the login page for everyone.

Why the admin API has a cap at all

The admin service is the one holding your database connection. A client stuck in a retry loop can otherwise keep it busy enough to slow the portal down for every project on the instance.

Raise it if a team regularly works in pages that load many panels at once; ten per second is comfortable for normal portal use. If your Redis is unreachable the limit is skipped rather than enforced, so a Redis outage cannot lock anyone out of the portal.


Step 1 — Create your .env ​

Copy docker/production/env.example to docker/production/.env next to the compose file. The admin and every worker share the same .env:

bash
cp docker/production/env.example docker/production/.env

At minimum verify the key environment variables:

env
#====================== ENVIRONMENT ======================
NODE_ENV=production
ENVIRONMENT=production

#====================== DATABASES ======================
PG_URL=postgres://postgres:postgres@postgres:5432/fluxify_alpha
REDIS_HOST=valkey
REDIS_PORT=6379

#====================== EVENT BUS ======================
NATS_URL=nats://nats:4222
NATS_TOKEN=fluxify_nats_token

#====================== SECURITY & KEYS ======================
MASTER_ENCRYPTION_KEY=<openssl rand -base64 32>
BETTER_AUTH_SECRET=<openssl rand -base64 32>
BETTER_AUTH_URL=https://your-domain.com

#====================== FIRST-RUN ADMIN ======================
[email protected]
SEED_USER_PASSWORD=ChangeThisPassword123!
SEED_USER_NAME=Admin User

#====================== PROJECTS SERVED BY WORKERS ======================
# Fill these in at Step 3, once the projects exist.
PROJECT_A_ID=
PROJECT_B_ID=

WARNING

Back up MASTER_ENCRYPTION_KEY. Losing or changing it after storing data makes every saved credential unreadable. Admin and workers must use the same value — your project's configuration travels to workers encrypted with it.

NOTE

The compose file already sets ENABLE_ADMIN=true on the admin service and keeps the workers as pure executors — you don't need to set those yourself.

Generate your secret keys ​

Use this generator to create secure values for MASTER_ENCRYPTION_KEY and BETTER_AUTH_SECRET, then paste them into your shared .env:


Step 2 — Start the control plane ​

bash
docker compose -f docker/production/docker-compose.yml up -d

This launches Traefik, the admin container, and the Postgres / Valkey / NATS dependencies. The admin container applies database updates on startup.

The worker services will not start yet — they need a project id, and there isn't one on a fresh install. Compose tells you so directly:

set PROJECT_A_ID in docker/production/.env

Step 3 — Create your projects, then start the workers ​

  1. Open http://your-domain.com/_/admin/ui and log in with the seed credentials.

  2. Create your projects.

  3. Copy each project's id from its settings page into docker/production/.env:

    env
    PROJECT_A_ID=<first-project-id>
    PROJECT_B_ID=<second-project-id>
    INTEGRATION_TIMEOUT_POLICY_IN_SEC=450
  4. Bring the stack up again:

    bash
    docker compose -f docker/production/docker-compose.yml up -d

The workers start and begin serving as soon as your routes reach them.

TIP

This is a one-time step per project. From here on, saving a route in the editor publishes it to every worker in place — no restart, no redeploy.


Step 4 — Access ​

SurfaceURL
Dashboardhttp://your-domain.com/_/admin/ui
Admin APIhttp://your-domain.com/_/admin/api
Project A's APIhttp://project-a.your-domain.com/
Project B's APIhttp://project-b.your-domain.com/

Scaling the workers ​

Raise the replica count on the claim that needs more workers. A project's claims are in its Orchestration settings, and the claim that serves every project is in the instance's. The orchestrator starts the extra workers on its next pass, and Traefik picks them up on its own, with no proxy change needed. Workers hold no state, so you can scale up and down freely, up to the node pool's ceiling.

Each claim also sets how much CPU and memory each of its workers may use. The default is 1 core and 1024 MB, adjustable in steps of 0.5 core and 256 MB. On Docker that is the container's limit. Resizing a claim replaces its workers one at a time. See Sizing a worker for the full ranges.

TIP

Scale out with replicas. Each worker container has one isolated execution process, so replicas add capacity without competing for the same CPU budget.

IMPORTANT

Scale workers, not the admin. Keep a single admin container so database updates and the seed step run exactly once.


Database integration idle timeout ​

Compiled workers share database clients across requests. To avoid retaining a pool for an integration that has gone quiet, set INTEGRATION_TIMEOUT_POLICY_IN_SEC in docker/production/.env:

env
INTEGRATION_TIMEOUT_POLICY_IN_SEC=450

450 is the default and means 7.5 minutes. When an integration has no in-flight request or transaction for that period, its PostgreSQL, MySQL, or MongoDB client closes. The next request opens a fresh client. This never closes a client while a request is using it; choose a larger value when avoiding a connection cold-start matters more than releasing idle sockets.


Health checks ​

Point your load balancer and orchestrator at port 5601, not 5600:

ProbeURL (port 5601)Means
Startup/_/admin/api/healthchecks/startupThe worker process is up
Readiness/_/admin/api/healthchecks/readyYour routes are loaded — safe to send traffic

Port 5600 carries your API traffic. A probe sent there can be answered by any one of the worker's internal handlers, so it can't tell you the whole container is healthy. The bundled compose file already targets 5601.


Upgrading ​

bash
docker compose -f docker/production/docker-compose.yml pull
docker compose -f docker/production/docker-compose.yml up -d

Roll the admin first (it applies any database updates), then the workers follow automatically.


Troubleshooting ​

Compose refuses to start with set PROJECT_A_ID … Expected on a first install — see Step 3.

Traffic returns 404 for /_/admin pages Traefik routes by path priority. Confirm the admin service still carries its PathPrefix(/_/admin) label and that the container is running.

Workers never receive traffic They stay out of rotation until the readiness check passes. Check a worker's logs — a NATS_TOKEN mismatch or a missing MASTER_ENCRYPTION_KEY is the usual cause. You can hit the probe directly from inside the network at /_/admin/api/healthchecks/ready on port 5601.

A worker exits immediately on start It refuses to run without WORKER_PROJECT_ID or MASTER_ENCRYPTION_KEY, and says which one is missing in its logs. Both come from your shared .env. A WORKER_MODE that is not route, workflow or both stops it the same way.

A second worker for a project will not start, complaining about a consumer Two workers on one project are set to different modes. See What a worker runs — every worker for a project has to agree. This is deliberate: the alternative is both of them running the same background job.

Routes save fine but never reach the workers NATS needs JetStream enabled (-js). The bundled compose file sets this; if you brought your own NATS, add the flag.

JetStream also backs two KV buckets, both created on demand — nothing to provision by hand, but they are what the -js flag is for:

BucketHolds
fluxify_artifactsCompiled routes and workflows, so a worker runs them without a database.
fluxify_configInstance settings and other cross-node config, so a flag change reaches every process.

fluxify_config is a distribution layer, not storage — Postgres remains the source of truth, and the admin container rebuilds the bucket from it on every start. Losing the NATS volume is therefore recoverable: restart admin and the bucket comes back.

The admin container exits at start complaining about instance settings Config is a hard dependency, not a degradable one, so a process that cannot reach NATS KV exits rather than come up half-configured and authenticate people against nothing. Check NATS_URL, NATS_TOKEN, and that JetStream is enabled.

Traefik can't see the services Traefik reads Docker labels through the mounted Docker socket. Ensure the socket volume is present and the services share the same network as Traefik.

Released under the Apache License 2.0. Enterprise features are under the Fluxify Enterprise Edition License.