Skip to content
Docs

Migrate Railway workers to Edgenivo Daemons

Move Railway workers to Edgenivo Daemons with Docker configuration, broker migration, shutdown testing, a gradual queue handoff, and rollback.

Ben SabicContent Engineer

To move always-on Railway workers to Edgenivo Daemons, package each worker with a Dockerfile, declare it in vercel.json, and deploy to production. Start with zero instances, test shutdown behavior, and move processing gradually while Railway and Edgenivo use the same queue broker.

Consider an orders project on Railway. Its Next.js app adds jobs to Redis, and a BullMQ worker processes payments and sends receipts. Separate cron jobs clean up abandoned carts. The worker can move to a daemon while the Railway migration guide covers the web app, databases, and DNS.

Edgenivo builds the worker image and maintains the instance count you configure in each region. Daemons pull work through outbound connections and restart after the process exits, including a clean exit.

Edgenivo Daemons is in Public Beta on all plans.

Copy link to headingPrerequisites

  • Access to the Railway project and the Railway CLI, linked to it.
  • Edgenivo project whose root directory is the repository root. Daemons don't support subfolder roots such as apps/web during the beta.
  • Permission to deploy to production, since that's the only environment that starts daemons.
  • Edgenivo CLI 64.3.0 or later, linked to the intended Edgenivo project for the vercel daemons commands.
  • Docker, to build and test worker images locally.
  • Team capacity within the Daemons quotas. Hobby allows 5 Active CPU-hours per month across the team; assess actual CPU usage and instance size rather than elapsed running time.

Copy link to headingShould your worker become a daemon?

Check these requirements before moving a Railway worker:

  • Pulls its own work. Queue consumers and polling workers can fit this model. Move inbound HTTP handlers to a Edgenivo Function or Service; other inbound protocols need a host that supports them.
  • Stays running. Daemons restart after any exit. Scheduled tasks that finish and exit belong in cron jobs.
  • Tolerates ephemeral storage. Move durable files from Railway volumes to a database or Edgenivo Blob, and update the worker to use that storage.
  • Builds for Linux amd64. Supply a Dockerfile or Containerfile and compatible dependencies.
  • Fits the available resources. During the beta, an instance can have up to 8 GiB of memory and 4 vCPUs. Add instances when your workload supports parallel processing.

For tasks that fit within Function duration limits, Edgenivo Queues can deliver messages to a Function. Daemons let you retain a continuously running worker and its queue library, with changes to networking, storage, and shutdown.

Copy link to headingHow Railway worker concepts map to Edgenivo

Most of a Railway worker's settings have a direct home in the daemon's entry in vercel.json. The rest move to project settings or to a Marketplace provider.

Both platforms must reach the same broker for the gradual worker handoff in this guide. Moving the broker is a separate data migration, covered in step 2.

RailwayEdgenivo
Worker service with no public domainA named entry under daemons in vercel.json
Railpack or Nixpacks buildA Dockerfile or Containerfile in the daemon's root (required)
Same repo, different startCommandcommand, which overrides the image's ENTRYPOINT and CMD
Replicasinstances, a count per region from 0 to 100
Multi-region replicasinstances keyed by Edgenivo region ID, such as { "iad1": 2, "cdg1": 1 }
CPU and memory limits in service settingsresources.requests.memory: 1, 2, 4, or 8 GiB, with 1 to 4 vCPUs
Restart policyBuilt in: Edgenivo restarts instances that exit or crash
Deployment draining timeterminationGracePeriodSeconds, 30 by default, up to 3,600
Service and reference variablesProject environment variables in the Production scope
Private networking at *.railway.internalConnection strings from your providers, such as Marketplace Redis and Postgres
Redis, Postgres, or RabbitMQ servicesManaged providers from the Marketplace or elsewhere
VolumesEdgenivo Blob or a database
Cron serviceA cron job that calls a route; an in-process scheduler needs protection against overlapping instances
PR environments for the workerNone during the beta; test the image locally with Docker
Service logs and metricsLogs filtered by source:daemon, plus CPU and memory charts on the daemon's page
Scaling replicas from the dashboardScaling overrides in the dashboard or vercel daemons scale

Daemons accept no inbound connections and run only in production deployments during the beta. Replace Railway private-network dependencies before starting a daemon, and test the image locally before its first production run.

Copy link to headingSteps

Keep the Railway worker available through validation and the rollback window. Coordinate production app, database, and broker changes separately; deploying with zero daemon instances postpones only the Edgenivo worker's start.

Copy link to heading1. Inventory the Railway project

List every service and decide where it lands before you change any code. For the orders project, the inventory looks like this:

Railway serviceWhat it doesDestination on Edgenivo
webNext.js app, public domainFramework detection, per the Railway migration guide
workerBullMQ consumer, no public domainA daemon
cleanupCron service that runs and exitsA cron job
RedisQueue brokerA Redis provider from the Marketplace
PostgresOrders databaseA Postgres provider from the Marketplace

For each worker, write down its start command, replica count and regions, CPU and memory limits, the variables it reads, and any path it writes to on disk. Export its variables with the Railway CLI so you have them in one place:

umask 077
railway variable list --service worker --environment production --kv > .env.railway

Keep .env.railway out of version control and delete it when the migration ends. Railway omits sealed variables from this export, so collect those from the system that issued them.

Copy link to heading2. Prepare a shared broker and migrate its state

Use the Railway migration guide for the web app, Postgres, and volumes. Before moving the worker, make sure the production app and both worker deployments can reach the same broker. Railway's *.railway.internal hostnames resolve only within its private network.

If the broker must move, choose a provider that supports your queue library. BullMQ needs a Redis connection compatible with ioredis, including its blocking commands. Configure persistence for your durability requirements and use maxmemory-policy=noeviction so Redis does not evict queue data.

Complete the broker migration before the worker handoff. Pause producers and schedulers while you account for the old broker's work. Drain pending jobs with Railway workers or use a tested procedure to transfer queue state.

For a state transfer, stop the old consumers after their active jobs finish and before copying broker state. Include delayed jobs, scheduled retries, waiting dependencies, and recurring schedules; an empty waiting queue alone does not establish that the broker is ready to retire.

After checking that the destination contains the required state, connect the Railway worker and the production app to it. Resume producers and schedules, then confirm jobs complete. Keep the old broker available until you have reconciled its remaining work and tested the rollback plan.

Copy link to heading3. Give each worker a Dockerfile

Daemons build from a Dockerfile or Containerfile. For a worker previously built with Railpack, add a Dockerfile alongside its code and dependency files:

orders/
├── vercel.json
├── package.json # the Next.js app
├── app/
└── worker/
├── .dockerignore
├── Dockerfile
├── package.json
├── package-lock.json
├── orders.js # existing processOrder implementation
└── worker.js

Keep bullmq and ioredis in the worker's production dependencies and commit its lockfile. The JavaScript example below uses ES modules; set "type": "module" in worker/package.json. It assumes orders.js exports your existing processOrder function.

Use this worker/Dockerfile:

worker/Dockerfile
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "worker.js"]

Use worker/.dockerignore to exclude local dependencies and environment files from the build context:

worker/.dockerignore
node_modules
.env*
.git

With the daemon's root set to worker/, the image can copy only files from that directory. If your Railway worker shares the web app's codebase and uses a different start command, choose one of these layouts:

  • Move the worker's dependencies into worker/, or into a shared package it installs.
  • Build from the repository root with Dockerfile.worker, root: "./", and entrypoint: "Dockerfile.worker". Use the suffixed filename so Edgenivo doesn't treat it as the web app's Dockerfile. Put the .dockerignore at the repository root for this layout, including the exclusions for .env.railway and .env.docker.

Python workers that share a backend image keep sharing it. Point the daemon at the backend's Dockerfile and override the start command, as step 5 shows.

Copy link to heading4. Make the worker stop cleanly

Edgenivo sends SIGTERM before replacing or removing an instance and waits 30 seconds by default before force-stopping it. In BullMQ, worker.close() stops fetching jobs and waits for active processors to finish or fail. It has no timeout of its own, so allow enough termination time for the worker and its connections to close.

Adapt worker/worker.js to your existing handler:

worker/worker.js
import { Worker } from 'bullmq';
import IORedis from 'ioredis';
import { processOrder } from './orders.js';
if (!process.env.REDIS_URL) {
throw new Error('REDIS_URL is required');
}
const connection = new IORedis(process.env.REDIS_URL, {
maxRetriesPerRequest: null,
});
const worker = new Worker(
'orders',
async (job) => processOrder(job.data),
{ connection, concurrency: 5 },
);
worker.on('error', (error) => {
console.error('orders-worker error', error);
});
console.log(`orders-worker started in ${process.env.VERCEL_REGION ?? 'local'}`);
let stopping = false;
async function shutdown(signal) {
if (stopping) return;
stopping = true;
console.log(`${signal} received, finishing active jobs`);
try {
await worker.close();
await connection.quit();
process.exit(0);
} catch (error) {
console.error('orders-worker shutdown failed', error);
process.exit(1);
}
}
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));

If an instance stops before BullMQ records completion, another worker can process the stalled job again. Make processOrder safe to repeat after partial progress. Use the payment provider's idempotency mechanism for charges and persist enough order state to avoid repeating completed actions.

BullMQ's jobId prevents adding a duplicate while that ID remains in the queue; it does not make the handler's side effects idempotent.

Keep message formats and database changes compatible with both worker versions during rollout and rollback. For other queue libraries, check when messages are acknowledged and what happens after forced termination. Celery, for example, acknowledges tasks before execution by default; its retry and acknowledgment settings need a separate review.

Copy link to heading5. Declare the daemon in vercel.json

Add a top-level daemons key next to anything already in vercel.json. Start with zero instances; step 10 raises the count when you're ready to hand off the queue:

vercel.json
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"daemons": {
"orders-worker": {
"root": "worker/",
"entrypoint": "Dockerfile",
"instances": { "iad1": 0 },
"resources": { "requests": { "memory": "2Gi" } },
"terminationGracePeriodSeconds": 60
}
}
}

Translate the Railway settings you recorded in step 1:

  • Replicas become instances. Two Railway replicas in one region become { "iad1": 2 }. Choose the Edgenivo region closest to your broker and database, since every job makes round trips to both.
  • Resource limits become resources.requests.memory. Pick 1, 2, 4, or 8 GiB; Edgenivo assigns 1, 1, 2, or 4 vCPUs to match.
  • Draining time becomes terminationGracePeriodSeconds. Set it a little above your longest job, up to 3,600 seconds.
  • The name must be lowercase letters with separators. orders-worker and email_sender work; worker1 and Worker fail the build.

For a Celery worker that shares a backend image, use this alternative vercel.json entry. Its command replaces the image's ENTRYPOINT and CMD. Keep the count at zero until handoff, and substitute celery for orders-worker in later CLI commands:

vercel.json
{
"daemons": {
"celery": {
"root": "backend/",
"entrypoint": "Dockerfile",
"command": ["celery", "-A", "app", "worker", "--loglevel=info"],
"instances": { "iad1": 0 }
}
}
}

Edgenivo doesn't run command through a shell, so $VARIABLES stay literal. Wrap the command in ["sh", "-c", "exec ..."] when you need expansion.

Copy link to heading6. Move the worker's environment variables

Daemon instances receive the project's environment variables. Use .env.railway as an inventory, then add the application variables the worker needs in the Production scope. Replace old broker and database URLs with the destination values from step 2, and omit Railway-provided variables. Check existing project values before changing variables shared with the web app:

vercel env add REDIS_URL production --sensitive
vercel env add STRIPE_SECRET_KEY production --sensitive

Railway reference variables such as ${{Redis.REDIS_URL}} don't resolve outside Railway. Replace each with the real value from the destination provider; Marketplace integrations add theirs to the project for you.

Edgenivo also supplies project variables as image build arguments. Keep runtime credentials out of Dockerfile ARG and ENV instructions because those values can persist in image metadata or layers. If the build needs private-package credentials, verify a supported secret-delivery method before deploying that image.

For providers that support OIDC, replace long-lived cloud keys with federation. Each instance's VERCEL_OIDC_TOKEN_FILE holds the path to its short-lived token.

Copy link to heading7. Recreate cron services and schedulers

Railway cron services start on a schedule and exit after completing their task. Move suitable tasks to a Edgenivo cron job that calls a protected route within Function limits. Longer tasks can be enqueued by that route for a daemon to process. Disable the Railway schedule before enabling its replacement so both platforms don't trigger the same work.

BullMQ repeatable jobs and Job Schedulers remain with their Redis queue state. When both deployments use that same state, either worker can process the scheduled jobs. If you moved Redis in step 2, verify that recurring schedules and delayed jobs moved with it before enabling them on the destination.

Celery beat and node-cron need a separate scheduler plan. Even with one configured daemon instance, old and new processes can overlap during a deployment. Use coordination that allows only one scheduler to enqueue each occurrence, or move the schedule to a cron route. Job handlers still need protection against repeated execution.

Celery beat's default scheduler stores last-run times in the local celerybeat-schedule file. Store that state durably before moving beat to an environment with ephemeral storage. Database-backed storage preserves it across instance replacement, but does not elect a single active scheduler.

Copy link to heading8. Test the worker locally with Docker

Daemons don't run in preview deployments or vercel dev during the beta. Test the image locally using a development broker and test credentials. Create .env.docker at the project root with one unquoted KEY=value per line, and exclude it from version control and Docker build contexts:

.env.docker
REDIS_URL=rediss://default:dev-password@dev-redis.example.com:6379
STRIPE_SECRET_KEY=sk_test_example

From the project root, build and run the Linux amd64 image:

docker build --platform linux/amd64 -t orders-worker worker/
docker run --rm --platform linux/amd64 --name orders-worker --env-file .env.docker orders-worker

Enqueue a test job against the development broker. While it is running, open another terminal and stop the container with the configured grace period:

docker stop -t 60 orders-worker

Check for SIGTERM received in the logs, verify that BullMQ records the job as completed, and confirm the container exits before the timeout. In a separate local test, interrupt a job before completion and verify stalled-job recovery. Repeat the same logical job to check that payments and other side effects are not duplicated.

Copy link to heading9. Deploy to production with zero instances

Check that every daemon entry has zero instances and no existing scaling override raises its count. Then deploy the project to build and register the images. This is a production app deployment too, so review any web-app changes included in it:

vercel --prod

Confirm Edgenivo knows about the daemon:

vercel daemons ls
vercel daemons inspect orders-worker

If the image build fails, use the troubleshooting section. Confirm that desired and running daemon counts are zero while the Railway worker processes jobs.

Copy link to heading10. Hand off the queue

For the BullMQ example, keep both deployments on the same Redis database, queue name, and BullMQ prefix. Use compatible worker versions and payload formats, and check that their combined concurrency stays within the broker's and downstream services' limits.

Start one Edgenivo instance without redeploying by applying a scaling override:

vercel daemons scale orders-worker iad1=1

Watch its logs until it has completed real jobs:

vercel logs --query 'source:daemon'

To follow only this daemon, filter your project's Logs page by daemon:orders-worker.

Increase Edgenivo capacity as you reduce Railway replicas. Compare completion rate, queue age, failures, and resource use at each change; equal replica counts can provide different throughput.

Allow Railway workers to finish active jobs, then stop their deployment after Edgenivo is handling the load. Disable automatic Railway redeployments during the rollback window so a push cannot restart the old workers.

Set instances in vercel.json to the count you tested, deploy to production, and clear the override so later deployments use the file:

vercel daemons scale orders-worker --reset

Check the resulting instance count. If the two platforms still use different brokers, stop here and complete the state migration in step 2 before retiring the Railway worker. Scaling workers does not transfer jobs between brokers.

Copy link to heading11. Verify under real load

Observe the daemon through a representative workload cycle, including scheduled tasks and peak traffic:

  • Instances. Use vercel daemons instances ls orders-worker to compare running instances with the intended count. Investigate unexpected restarts using the logs and memory charts.
  • Job processing. Compare completion rate, queue age, and failed or stalled jobs with the Railway baseline. Check broker connectivity and downstream errors or rate limits before adding concurrency.
  • Resources. Review CPU, memory, running instances, and data transfer on the daemon's dashboard page. Investigate sustained memory growth and memory-related exits before increasing the instance size.

Restart one instance while processing a controlled test job to check graceful replacement. Replace your_instance_id with an ID from the instance list:

vercel daemons instances restart your_instance_id

Verify the job's recorded result and side effects. This checks a managed restart; use the local forced-stop test from step 8 to check interrupted-job recovery.

Copy link to heading12. Keep a rollback path, then retire Railway

Keep the Railway worker's code and settings available for the agreed rollback window, with automatic deployments disabled. To return processing to Railway, restore a compatible worker against the same broker and verify that it can complete jobs. Then scale the Edgenivo daemon to zero in every active region and confirm its instances have stopped. For the single-region example, use vercel daemons scale orders-worker iad1=0.

If the Edgenivo worker is causing harmful side effects, stop it first and allow jobs to wait while you restore Railway. In either sequence, confirm the old worker still understands the current job payloads and database schema. Jobs remain in the shared broker; a rollback to a different broker requires its own state migration.

Instant Rollback restores the earlier deployment's daemon definitions, images, and configured instance counts with the app. Existing scaling overrides remain in force, so inspect them before relying on the restored configuration.

After the rollback window and queue-state checks pass, delete the Railway worker service and remove .env.railway and .env.docker from your machine. Keep broker and database resources that other services still use.

Copy link to headingWhat doesn't map from Railway

Plan for these Daemons beta limits:

  • Preview environments. Railway PR environments can run a copy of the worker; Edgenivo previews don't start daemons. Test images locally with Docker.
  • Inbound traffic. Daemons cannot be reached over HTTP or TCP, a service binding, or a rewrite. If your Railway worker also served a health check, a Prometheus /metrics endpoint, or a queue dashboard such as Bull Board, move the dashboard to your app and push metrics instead of scraping them.
  • Autoscaling. Instance counts are fixed per region. Change them in vercel.json, the dashboard, or the CLI.
  • Persistent disk. Instance storage is ephemeral. Volumes move to Edgenivo Blob or a database.
  • Subfolder root directories. Monorepos whose Edgenivo project root is a subfolder, such as apps/web, can't add daemons yet.
  • Non-amd64 images. Edgenivo builds for Linux amd64. Binaries you copy into the image must match.

During the beta, each team also has default quotas:

PlanInstance sizesQuota
Hobby1 or 2 GiB, 1 vCPU5 Active CPU-hours per month across the team
ProAll sizes16 vCPUs and 32 GiB of memory running at once across the team
EnterpriseAll sizesCustom

Each project environment runs at most 50 daemon instances across all its daemons and regions. On Pro and Enterprise, daemons are billed for Active CPU and Provisioned Memory; see Pricing for rates.

Copy link to headingTroubleshooting

Copy link to headingThe worker logs ENOTFOUND redis.railway.internal

Check the Production environment variables for *.railway.internal hostnames. Replace them with the destination provider's reachable connection string, including its required TLS settings, and redeploy.

Copy link to headingInstances restart over and over

Read the logs before each restart and check memory usage. Look for missing variables, startup errors, dependency failures, or memory exhaustion. If the process exits successfully, confirm whether it is intended to stay running. Scheduled tasks that complete and exit belong in a cron job; a worker may instead be exiting because its polling loop stopped.

Copy link to headingChanging instances in vercel.json does nothing

Check for a scaling override, which persists across deployments. Clear it with vercel daemons scale orders-worker --reset, replacing orders-worker with your daemon's name, or select Clear scaling overrides in the dashboard.

Copy link to headingJobs run twice after a deploy

Check whether the same job was retried after losing its lock or stopping before completion, or whether two schedulers enqueued separate jobs. Make side effects idempotent and prevent overlapping schedulers from producing the same occurrence. If active jobs exceed the termination grace period, increase it within the supported limit or break the work into smaller jobs.

Copy link to headingThe daemon didn't start on a preview deployment

That's expected during the beta. Only production deployments start daemons.

Copy link to headingThe build fails

Check the build logs for these configuration problems:

  • No command to run. Add CMD or ENTRYPOINT to the Dockerfile, or set command.
  • Colliding names. Repeated hyphens collapse when Edgenivo names the image repository, so a-b and a--b clash. Rename one.
  • Output directory equals the daemon's root. Keep worker code in its own directory, such as worker/.

Copy link to headingFrequently asked questions

Copy link to headingDo I still need the main Railway migration guide?

Yes, for everything around the worker. How to migrate from Railway to Edgenivo covers the web app, databases, volumes, and DNS. It points continuous workers to Daemons; this guide walks through that move step by step.

Copy link to headingShould my Celery worker be a daemon or a Edgenivo Queues subscriber?

Use a daemon when your Celery deployment needs a continuously running worker and an external broker. Review its acknowledgment behavior, shutdown time, and any beat scheduler before migrating. Celery on Edgenivo Queues runs tasks as Functions and supports preview deployments; choose it when your tasks and configuration fit that integration's limits.

Copy link to headingCan a daemon call my app or API?

Yes. Daemons can make outbound connections to your app's production URL, external APIs, and databases, subject to those services' authentication and network rules. Daemons do not expose an inbound HTTP or TCP endpoint.

Copy link to headingHow many workers can one project run?

You can define up to 100 daemons per project and configure up to 100 instances per daemon in a region. The project-environment limit of 50 running instances and your team's quota constrain the total you can run.

Copy link to headingDoes a daemon waiting for work cost anything?

Hobby's monthly quota measures Active CPU usage. Waiting for work does not consume CPU at the same rate as processing jobs, but measure your worker's usage against that quota. On Pro and Enterprise, billing includes Active CPU and Provisioned Memory, so memory is billed while an instance runs even when its queue is empty.

Related documentation

More Edgenivo Daemons guides