Procfile to bunnyshell.yaml: Translating Your Heroku Process Types
GuideSeptember 29, 202612 min read

Procfile to bunnyshell.yaml: Translating Your Heroku Process Types

The Short Version

A Procfile is a short, declarative list: one line per process type, each naming the command that starts it. That same declarative spirit carries over directly into bunnyshell.yaml, just expressed as one component per process type instead of one line per process. The bigger practical change is upstream of the Procfile itself: Heroku's buildpack, which auto-detects a language and installs dependencies with no configuration, gets replaced by an explicit Dockerfile, since Bunnyshell builds from a Dockerfile rather than inferring one.

A Typical Procfile, Translated

Text
1web: bundle exec puma -C config/puma.rb
2worker: bundle exec sidekiq -C config/sidekiq.yml
3release: bundle exec rake db:migrate

Each of these three lines becomes its own component:

YAML
1components:
2  - kind: Application
3    name: web
4    dependsOn: [release]
5    dockerCompose:
6      build:
7        context: .
8      command: 'bundle exec puma -C config/puma.rb'
9    hosts:
10      - hostname: 'web-{{ env.base_domain }}'
11        servicePort: 3000
12
13  - kind: Application
14    name: worker
15    dependsOn: [release]
16    dockerCompose:
17      build:
18        context: .
19      command: 'bundle exec sidekiq -C config/sidekiq.yml'
20
21  - kind: GenericComponent
22    name: release
23    dependsOn: [postgres]
24    runnerImage: '@self'
25    dockerCompose:
26      build:
27        context: .
28    deploy:
29      - 'bundle exec rake db:migrate'

The command each process type ran on Heroku carries over directly as the command field (or, for a GenericComponent, as the deploy step), and the only genuinely new piece is dependsOn, which makes explicit what Heroku's release phase handled implicitly: nothing that serves traffic or processes jobs starts until the release step, the migration, has actually completed.

Replacing the Buildpack With a Dockerfile

Heroku's buildpack inspects a repository, detects the language and framework, and builds a runtime image with zero configuration. This convenience comes at the cost of control: customizing system dependencies, pinning specific tool versions, or doing anything a buildpack's maintainers didn't anticipate can range from awkward to impossible. A Dockerfile trades a small amount of upfront setup work for removing that ceiling entirely.

Dockerfile
1FROM ruby:3.3-slim AS base
2WORKDIR /app
3RUN apt-get update && apt-get install -y build-essential libpq-dev
4COPY Gemfile Gemfile.lock ./
5RUN bundle install
6
7FROM base AS web
8COPY . .
9CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
10
11FROM base AS worker
12COPY . .
13CMD ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]

A single multi-stage Dockerfile with one build target per process type, rather than fully separate Dockerfiles, is the cleaner pattern when process types share most of their dependency stack, which is the common case for a web and worker process pulled from the same codebase. Each component's dockerCompose.build.target then selects the matching stage:

YAML
1components:
2  - kind: Application
3    name: web
4    dockerCompose:
5      build:
6        context: .
7        target: web
8
9  - kind: Application
10    name: worker
11    dockerCompose:
12      build:
13        context: .
14        target: worker

Translating app.json's Formation and Config

app.json describes, among other things, the formation (how many dynos of each type, and what size) and the config vars a fresh app instance needs:

JSON
1{
2  "env": {
3    "RAILS_ENV": { "value": "production" },
4    "SECRET_KEY_BASE": { "generator": "secret" }
5  },
6  "formation": {
7    "web": { "quantity": 2, "size": "standard-1x" },
8    "worker": { "quantity": 1, "size": "standard-1x" }
9  },
10  "addons": ["heroku-postgresql:standard-0"]
11}

The env block maps directly to environmentVariables, with a generated secret becoming a SECRET[] value instead. The formation block's dyno quantity maps to replica count on the equivalent component, and dyno size maps to a Kubernetes resource request and limit:

YAML
1environmentVariables:
2  RAILS_ENV: production
3  SECRET_KEY_BASE: SECRET['generated-secret-value']
4
5components:
6  - kind: Application
7    name: web
8    k8s:
9      replicas: 2
10      resources:
11        requests:
12          cpu: '500m'
13          memory: '512Mi'
14        limits:
15          memory: '1Gi'

There's no exact one-to-one mapping from a specific dyno size to a specific Kubernetes resource request, since the two describe capacity differently; using a dyno size's documented RAM and CPU allocation as a starting point, then adjusting based on actual observed usage once the component is running, is the practical way to arrive at a reasonable setting rather than guessing.

Scaling: heroku ps:scale to Replica Count

Scaling a process type up or down on Heroku (heroku ps:scale web=4) maps to changing the same replica count field, either as a static value in the template for a fixed scaling need, or set conditionally based on environment type for workloads that genuinely need different scaling between, say, a production environment and an ephemeral PR preview:

YAML
k8s:
  replicas: {% if env.type == "ephemeral" %}1{% else %}4{% endif %}

This conditional pattern is worth using specifically when the same template serves both a scaled-up long-lived environment and lightweight ephemeral previews, avoiding either over-provisioning every throwaway PR environment or under-provisioning the real production-facing one.

One-Off Dynos and Interactive Access

heroku run rails console opens a one-off dyno for an interactive session, most often a console or a manual debugging task. The direct equivalent is bns exec against a running component, which opens a shell inside the actual running container rather than spinning up a separate throwaway instance:

Bash
$ bns exec --component <WEB_COMPONENT_ID> -- bundle exec rails console

For a scripted one-off task rather than an interactive session (a data backfill, a one-time cleanup script), a dedicated GenericComponent with its own deploy script, run on demand rather than as part of every deploy, better matches the intent: a task that runs once, deliberately, rather than something that needs to happen on every deploy cycle.

Handling Multiple Codebases or a Monorepo

Heroku's model assumes one app per Heroku application, which for a monorepo serving multiple services sometimes meant either multiple separate Heroku apps pointed at subdirectories, or contorting a Procfile to run multiple things under one app. Bunnyshell's per-component gitApplicationPath handles this more directly, letting each component build from its own subdirectory of a single repository without needing separate Heroku apps or awkward workarounds:

YAML
1components:
2  - kind: Application
3    name: api
4    gitRepo: 'https://github.com/org/monorepo.git'
5    gitApplicationPath: /services/api
6
7  - kind: Application
8    name: worker
9    gitRepo: 'https://github.com/org/monorepo.git'
10    gitApplicationPath: /services/worker

Verifying the Translation Actually Works

Before considering a process type fully migrated, deploy the translated component and confirm it behaves identically to its Heroku counterpart: same startup log lines, same response to a basic health check, same job processing behavior for a worker. This is worth doing process type by process type, not all at once, since isolating a discrepancy to a single component is considerably easier than debugging an entire freshly-translated stack where several things might be subtly wrong simultaneously.

Bash
1$ bns environments create --from-path bunnyshell.yaml \
2    --name "heroku-migration-test" --project <PROJECT_ID> --k8s <CLUSTER_ID> --deploy
3
4$ bns logs --component <WEB_COMPONENT_ID> --tail 50

Environment Variables Heroku Sets Automatically

Heroku injects a handful of environment variables automatically that application code sometimes comes to depend on without anyone realizing it until migration time: PORT (the port the web process must bind to, dynamically assigned by Heroku's router), DYNO (an identifier for the current dyno, occasionally used in logging), and HEROKU_APP_NAME among others. None of these exist automatically outside Heroku, so any code path reading them needs an explicit equivalent:

YAML
1components:
2  - kind: Application
3    name: web
4    dockerCompose:
5      environment:
6        PORT: '3000'
7    hosts:
8      - hostname: 'web-{{ env.base_domain }}'
9        servicePort: 3000

The practical fix for PORT specifically is setting it explicitly as a fixed value matching servicePort, since there's no dynamic router assigning it the way Heroku's did; most frameworks that read process.env.PORT (or the equivalent in another language) work correctly once this is set to a fixed, known value rather than expecting it to vary. A codebase with several places reading Heroku-specific variables is worth grepping for before considering the translation complete, rather than discovering each one individually when a specific code path fails at runtime.

Bash
$ grep -rn "ENV\['PORT'\]\|process.env.PORT\|ENV\['DYNO'\]\|HEROKU_APP_NAME" .

Custom and Multiple Buildpacks

A Heroku app using multiple buildpacks (common for a Node.js app that also needs a system dependency a buildpack add-on installs, or a custom buildpack for something buildpacks don't cover natively) needs each buildpack's actual effect translated into an explicit RUN step in the Dockerfile, since there's no equivalent buildpack-stacking mechanism:

Dockerfile
1# Equivalent of a Node.js buildpack + an apt-buildpack for a system dependency
2FROM node:20-slim AS base
3RUN apt-get update && apt-get install -y ffmpeg
4WORKDIR /app
5COPY package*.json ./
6RUN npm ci
7COPY . .

This is usually a small amount of work once the actual buildpacks in use are identified, since most buildpacks do something conceptually simple (install a language runtime, install a system package, run a build command) that translates into one or two Dockerfile lines; the effort is mostly in tracking down exactly what each buildpack was doing, which heroku buildpacks (listing the buildpacks configured for an app) is worth running before starting the translation, so nothing gets missed.

For the full migration overview this process-type translation fits into, see Migrating from Heroku to Bunnyshell. For the PR-preview workflow migration, once the base stack is running correctly, see Heroku Review Apps to Preview Environments.

Checklist

  • Every Procfile process type has a corresponding component, with the exact same start command
  • A Dockerfile (single multi-stage or per-process-type) replaces the buildpack, tested locally before relying on it in a pipeline
  • app.json's formation quantities and config vars are translated to replica counts and environmentVariables
  • The release phase runs as a dependsOn-ordered step before any traffic-serving or job-processing component starts
  • Each translated process type is verified individually against its Heroku counterpart before considering the migration complete
✦✦

Turn your Procfile into a real Kubernetes stack.

14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.

Frequently Asked Questions

Does every Procfile process type need its own component?

Yes, in the same way every process type gets its own dyno type on Heroku. A web process and a worker process run different code paths against potentially different scaling needs, so each becomes its own component with its own resource settings and, for web, its own hosts entry.

What replaces Heroku's automatic buildpack detection?

An explicit Dockerfile. Buildpack detection trades a small amount of setup effort for giving up control over the exact runtime image; a Dockerfile requires writing that setup once but gives full control over base image, installed system dependencies, and build steps that buildpacks sometimes make difficult or impossible to customize.

How does heroku ps:scale map to Bunnyshell?

Scaling dyno count for a process type maps to setting replica count on the equivalent component, either statically in bunnyshell.yaml or dynamically via a deploy pipeline step, depending on whether the scaling need is fixed or should vary by environment type.

What about one-off dynos, like heroku run rails console?

The equivalent is bns exec against a running component's pod, which opens an interactive shell inside the actual running container, or a dedicated GenericComponent with its own deploy script for a scripted one-off task rather than an interactive session.

Do I need a separate Dockerfile for every process type?

Not necessarily. A single multi-stage Dockerfile with different build targets (one per process type) is usually cleaner than fully separate Dockerfiles, especially when process types share most of their dependencies, which is the common case for web and worker processes from the same codebase.