We've raised a $22m Series A →
Conductor

Quickstart

Rails

John Nunemaker's tweet about using Conductor with Rails

Conductor works well with Rails apps. The key things to remember:

  1. Default PORT to CONDUCTOR_PORT so each workspace gets its own port
  2. Make sure Action Mailer, controllers, and other URL generators use that port
  3. Symlink git-ignored files from your root repo to each workspace

Configuration Files

Here's a complete setup based on John Nunemaker's gist.

conductor.json

Add this to the root of your project:

{
  "scripts": {
    "setup": "bin/conductor-setup",
    "server": "script/server"
  }
}

bin/conductor-setup

Create a setup script that symlinks shared configuration from your root repo:

#!/bin/sh
#/ Usage: bin/conductor-setup
#/
#/ Setup files that are not tracked in git for Conductor workspaces.
#/ This is run automatically when a new workspace is created.

set -e
cd $(dirname "$0")/..

# Symlink .env from the root repo so all workspaces share the same config.
# Set up your .env once at $CONDUCTOR_ROOT_PATH/.env and all workspaces use it.
if [ -n "$CONDUCTOR_ROOT_PATH" ]; then
  if [ -f "$CONDUCTOR_ROOT_PATH/.env" ]; then
    echo "Symlinking .env from $CONDUCTOR_ROOT_PATH/.env..."
    ln -sf "$CONDUCTOR_ROOT_PATH/.env" .env
  else
    echo "Warning: $CONDUCTOR_ROOT_PATH/.env not found."
    echo "Create it from .env.example: cp .env.example $CONDUCTOR_ROOT_PATH/.env"
  fi

  # Copy database.yml and credential keys from repo root
  if [ -f "$CONDUCTOR_ROOT_PATH/config/database.yml" ]; then
    echo "Copying database.yml from repo root..."
    cp "$CONDUCTOR_ROOT_PATH/config/database.yml" config/database.yml
  fi

  if [ -f "$CONDUCTOR_ROOT_PATH/config/credentials/development.key" ]; then
    echo "Copying development.key from repo root..."
    cp "$CONDUCTOR_ROOT_PATH/config/credentials/development.key" config/credentials/development.key
  fi

  if [ -f "$CONDUCTOR_ROOT_PATH/config/credentials/test.key" ]; then
    echo "Copying test.key from repo root..."
    cp "$CONDUCTOR_ROOT_PATH/config/credentials/test.key" config/credentials/test.key
  fi

  # Symlink storage directory for Active Storage
  if [ -d "$CONDUCTOR_ROOT_PATH/storage" ]; then
    echo "Symlinking storage from $CONDUCTOR_ROOT_PATH/storage..."
    ln -sf "$CONDUCTOR_ROOT_PATH/storage" storage
  else
    echo "Creating storage directory at $CONDUCTOR_ROOT_PATH/storage..."
    mkdir -p "$CONDUCTOR_ROOT_PATH/storage"
    ln -sf "$CONDUCTOR_ROOT_PATH/storage" storage
  fi

  # Symlink ngrok.yml from repo root if it exists
  if [ -f "$CONDUCTOR_ROOT_PATH/ngrok.yml" ]; then
    echo "Symlinking ngrok.yml from $CONDUCTOR_ROOT_PATH/ngrok.yml..."
    ln -sf "$CONDUCTOR_ROOT_PATH/ngrok.yml" ngrok.yml
  fi

  # Symlink .bundle for private gem credentials
  if [ -d "$CONDUCTOR_ROOT_PATH/.bundle" ]; then
    if [ -e .bundle ] && [ ! -L .bundle ]; then
      echo "Error: .bundle exists and is not a symlink. Remove it manually first."
      exit 1
    fi
    echo "Symlinking .bundle from $CONDUCTOR_ROOT_PATH/.bundle..."
    ln -sf "$CONDUCTOR_ROOT_PATH/.bundle" .bundle
  fi
else
  # Fallback for running outside Conductor
  if [ ! -f .env ]; then
    echo "Creating .env from .env.example..."
    cp .env.example .env
  else
    echo ".env already exists, skipping..."
  fi
fi

# Run full setup (dependencies, database, fixtures)
script/bootstrap

echo "Conductor setup complete!"

Make it executable: chmod +x bin/conductor-setup

script/server

Create a server script that uses CONDUCTOR_PORT:

#!/bin/sh
#/ Usage: script/server
#/
#/ Run all the processes necessary for the app.

set -e
cd $(dirname "$0")/..

[ "$1" = "--help" -o "$1" = "-h" -o "$1" = "help" ] && {
    grep '^#/' <"$0"| cut -c4-
    exit 0
}

# Use CONDUCTOR_PORT if set, otherwise PORT, defaulting to 3000
# Set Vite port to PORT + 36 (e.g., 3000 -> 3036)
export PORT=${CONDUCTOR_PORT:-${PORT:-3000}}
export VITE_RUBY_PORT=$((PORT + 36))

bundle exec foreman start -p ${PORT} -f Procfile.dev

Make it executable: chmod +x script/server

config/initializers/default_host.rb

This initializer ensures Action Mailer, controllers, and asset hosts all use the correct port:

canonical_host = ENV["CANONICAL_HOST"]

CANONICAL_HOST = if canonical_host
  canonical_host
elsif Rails.env.development?
  "localhost:#{ENV.fetch("PORT", 3000)}"
elsif Rails.env.test?
  "www.example.com"
else
  "#{ENV["HEROKU_APP_NAME"]}.herokuapp.com"
end

scheme = Rails.configuration.force_ssl ? "https" : "http"
APP_URL = "#{scheme}://#{CANONICAL_HOST}"

Rails.application.routes.default_url_options[:host] = CANONICAL_HOST
Rails.application.config.action_mailer.default_url_options = { host: CANONICAL_HOST }
Rails.application.config.asset_host = CANONICAL_HOST

if canonical_host
  Rails.application.middleware.use Rack::CanonicalHost, canonical_host
end

config/puma.rb

Configure Puma to use the PORT environment variable:

workers_count = Integer(ENV['WEB_CONCURRENCY'] || 1)
max_threads_count = Integer(ENV['RAILS_MAX_THREADS'] || 2)
min_threads_count = Integer(ENV['RAILS_MIN_THREADS'] || max_threads_count)
threads min_threads_count, max_threads_count

port        ENV['PORT']     || 3000
environment ENV['RACK_ENV'] || ENV['RAILS_ENV'] || 'development'

enable_keep_alives false

if workers_count > 1
  preload_app!
  workers workers_count

  before_fork do
    if defined?(::ActiveRecord) && defined?(::ActiveRecord::Base)
      ApplicationRecord.connection_pool.disconnect!
    end
  end

  on_worker_boot do
    if defined?(::ActiveRecord) && defined?(::ActiveRecord::Base)
      ApplicationRecord.establish_connection
    end
  end
end

if ENV['RACK_ENV'] == 'development' || ENV['RAILS_ENV'] == 'development' || (ENV['RACK_ENV'].nil? && ENV['RAILS_ENV'].nil?)
  on_booted do
    port = ENV['PORT'] || 3000
    url = "http://localhost:#{port}"
    puts
    puts "  App running at #{url}"
    puts
  end
end

plugin :tmp_restart

vite.config.js (if using Vite)

If you're using Vite with Rails, configure it to use a port offset:

import { defineConfig } from 'vite'
import ViteRails from 'vite-plugin-rails'
import vue from '@vitejs/plugin-vue'

// Default Vite port to PORT + 36 (e.g., Rails on 4000 -> Vite on 4036)
const basePort = parseInt(process.env.PORT || '4000', 10)
const vitePort = parseInt(process.env.VITE_PORT || String(basePort + 36), 10)

export default defineConfig({
  server: {
    port: vitePort,
    strictPort: true,
  },
  plugins: [
    ViteRails(),
    vue()
  ]
})

Using Caddy for HTTPS

You can also use Caddy in your Procfile to handle multiple domains with HTTPS and redirect localhost. This makes local development match production more closely.

On this page