rails new: Complete Guide to All Options

May 13, 2025

Every time I run the rails new command I try to remember what options I prefer - of course, you can change things later, but if you’re looking to get up and running quickly it’s nice to get it mostly correct on the first try. I did an audit of all the options as of rails 8.0.2 and grouped them in a way that made sense to me, since the default output provided by rails new --help can be difficult to parse through.

Bullet Train

Options You Definitely Want to Specify

--database=DATABASE specifies the database adapter to use. The options are sqlite3, postgresql, mysql, oracle, sqlserver, jdbc, and none. The default is sqlite3.

--javascript=JAVASCRIPT tells Rails which JavaScript bundler or integration to set up for your new application. The possible values are importmap, bun, webpack, esbuild, and rollup. The default is importmap.

--css=CSS tells Rails which CSS processor to use for your new application. The possible values are tailwind, bootstrap, bulma, postcss, and sass. The default is tailwind.

Options To Skip Components You Don’t Need

--skip-action-mailer tells Rails not to include Action Mailer in your new app. This means Rails will not generate any email-related folders like app/mailers, not configure default mailer settings in config/environments/* and not include action_mailer in config/application.rb.

--skip-action-mailbox tells Rails not to include Action Mailbox in your new application. Action Mailbox is a Rails framework that lets your application receive inbound emails and process them as part of your business logic.

--skip-action-text tells Rails not to include Action Text in your new application. Action Text is a built-in Rails framework for rich text content, powered by the Trix editor. It allows users to write formatted text and embed images and attachments.

--skip-active-record tells Rails not to include Active Record, the built-in ORM (Object-Relational Mapping) framework. This is probably the trickiest option to add later on if you decide to skip it at first.

--skip-active-job tells Rails not to include Active Job, the framework Rails provides for background job abstraction.

--skip-active-storage tells Rails not to include Active Storage, the built-in framework for file uploads and attachments. If you choose to skip it at first you can add it later with bin/rails active_storage:install.

--skip-action-cable tells Rails not to include Action Cable, which is Rails’ built-in framework for WebSockets and real-time communication.

--skip-asset-pipeline tells Rails not to include any asset pipeline, meaning it won’t set up tools to manage and compile JavaScript, CSS, or images.

--skip-javascript tells Rails not to set up any JavaScript tooling or files in your new application. By default Rails adds package.json, app/javascript/application.js, JavaScript helpers like @hotwired/turbo-rails and @rails/ujs and the relevant config based on your JavaScript approach (see --javascript=JAVASCRIPT).

--skip-hotwire tells Rails not to include Hotwire, the default real-time frontend stack introduced in Rails 7. This includes Turbo and Stimulus.

--skip-jbuilder tells Rails not to include Jbuilder, which is the default JSON response templating library in Rails. Jbuilder lets you build JSON responses using Ruby in .json.jbuilder templates. To add it later you simply add it to your Gemfile and run bundle install.

--skip-test tells Rails not to generate the default test framework, which is Minitest, and to skip creating test files entirely. This will exclude minitest from the Gemfile, skip creating the test folder, and also not generate test files if you’re using rails generate controller/model. Use it if you’re using RSpec or another test framework

--skip-system-test tells Rails not to set up system tests, which are end-to-end browser-based tests using Capybara. This option skips generating the test/system folder, application_system_test_case.rb, and does not configure Capybara or install any system test drivers. --skip-test implies that system tests will also be skipped, so --skip-system-test is redundant unless used alone.

--skip-bootsnap tells Rails not to include Bootsnap, which is a performance optimization library that speeds up boot time by caching expensive operations.

--skip-dev-gems is a new option in Rails 8 and tells Rails to omit development-specific gems from the generated application’s Gemfile. These gems are typically included to enhance the development experience but are not necessary for production environments, e.g. web-console and listen.

--skip-thruster is a new option in Rails 8 and tells Rails to exclude the setup for Thruster, a new HTTP/2 proxy.

--skip-rubocop tells Rails not to include the RuboCop gem and .rubocop.yml configuration file. In Rails 7.2, the rails new command began including RuboCop by default in newly generated applications.

--skip-brakeman tells Rails not to include Brakeman, a static analysis security scanner for Ruby on Rails applications. Brakeman scans your Rails codebase for potential security vulnerabilities — without needing to run the app or its tests. It’s commonly used in CI pipelines or local development to catch problems early.

--skip-ci tells Rails not to create the GitHub Actions CI workflow in .github/workflows/ci.yml. By default this Workflow runs tests and Rubocop.

--skip-kamal tells Rails to exclude the default setup for Kamal, a deployment tool integrated into Rails to simplify application deployment. To add it later you add it to your Gemfile, run bundle install, and then bin/kamal init.

--skip-solid allows you to exclude the default setup for Solid components, which include Solid Cache (A caching backend that stores cached data in the database), Solid Queue (A database-backed job queue system that serves as the default Active Job backend), and Solid Cable (A database-backed Action Cable adapter for real-time features).

--skip-docker tells Rails to skip generating the docker config like Dockerfile and bin/docker-entrypoint. By default Rails will not generate the docker config, but this guards against a template or future config change enabling it by default. There is also a --docker option, but it’s not listed in rails new --help, because it’s considered an internal or ‘hidden’ option right now.

--devcontainer, --no-devcontainer, and --skip-devcontainer control whether a Dev Container configuration is generated for Visual Studio Code’s Remote - Containers / Dev Containers feature. --no-devcontainer and --skip-devcontainer are synonyms. By default Rails will not generate Dev Containers.

Options For the Generator Itself

--skip-collision-check tells Rails to overwrite existing files. By default, Rails will refuse to overwrite existing files like Gemfile, .gitignore, etc.

--ruby=PATH changes the shebang (#!) at the top of generated scripts like bin/rails and bin/rake. The default is #!/usr/bin/env ruby.

template=TEMPLATE allows you to specify a custom Ruby script that will run during project generation. It allows you to automate additional setup steps, like adding gems that you always include (devise, rubocop, standardrb, etc) and run generators. TEMPLATE can be a path to a file or a remote URL.

--skip-git tells Rails to skip all the git commands after generating the new app. By default Rails will run git init, stage all the files with git add . and make an initial commit. This option also skips the .gitignore file.

--skip-keeps means Rails won’t create any .keep files — so empty folders will truly be empty. That means they may be missing from Git until something is added.

--rc=RC lets you specify a file path for load additional options for the rails new command. By default, Rails looks for a .railsrc file in your home directory (~/.railsrc). If you want to prevent Rails from loading any .railsrc file, you can use the --no-rc option.

--skip-bundle tells Rails not to run bundle install automatically after generating the new app.

--skip-decrypted-diffs tells Rails not to setup a Git filter to show decrypted diffs for credential files. Rails uses encrypted credentials (like config/credentials.yml.enc) to securely store sensitive information. By default, Rails can configure Git to automatically show the decrypted contents of these files when viewing diffs, making it easier to see what has changed.

Grouped Options

--api, --no-api, and --skip-api control whether the generated application is a full-stack Rails app or a lightweight API-only app. API-only apps are optimized for serving JSON and exclude views, helpers, assets, and various other behaviors not typically needed in API-only applications. By default Rails assumes you are building a full-stack application, so equivalent to --non-api.

--minimal creates a lightweight Rails application by excluding several default frameworks and tools. This applies several of the --skip-* options and excludes components such as Active Job, Action Mailer, Action Mailbox, Active Storage, Action Text, Action Cable, JavaScript, Hotwire, Jbuilder, System tests, Bootsnap, Development gems, Brakeman, Rubocop, CI configuration files, Docker setup, Kamal, Solid components, and Thruster. By default Rails assumes you are building a non-minimal application, so equivalent to --non-minimal.

Options You Will Probably Never Use

--dev, --no-dev, and --skip-dev are special internal flags meant for Rails contributors or advanced users working on Rails itself. This allows you to tell Rails to generate the app using the local checkout of the Rails framework, instead of pulling gems from rubygems.org.

--edge, --no-edge, and --skip-edge control whether the newly generated application uses the edge branch/version of Rails through configuration in the Gemfile. --no-edge / --skip-edge are aliases that explicitly say: ‘don’t use edge Rails.’ They’re rarely needed unless you’re overriding an inherited or default behavior. Similarly, the --master, --main, --no-main, and --skip-main control the same behavior, but for pointing at the main branch.

Options for Engines and Plugins

--skip-namespace is used when generating engines or plugins, not during normal app creation. So if you’re doing rails new myapp then it does nothing. If you’re generating a plugin, i.e. rails plugin new my_plugin by default, Rails namespaces everything under the plugin name — so MyPlugin::Engine, MyPlugin::ApplicationController, etc. This options allows you to skip that.

--name=NAME is used with the rails plugin new and rails engine new generators - and not with normal app creation. It sets the internal Ruby module/class name for the plugin or engine independent of the directory name.

My Default Options

Here are my default options for rails new:

rails new my_app \
  --database=postgresql \
  --javascript=esbuild \
  --css=bootstrap \
  --skip-action-mailbox \
  --skip-action-text \
  --skip-action-cable \
  --skip-jbuilder \
  --skip-test \
  --skip-thruster \
  --skip-kamal \
  --skip-solid \
  --skip-decrypted-diffs
  • --skip-test because I generally use rspec, not minitest.
  • --skip-jbuilder since I can easily add it if I need it later on.
  • --skip-solid because I only really use Solid Queue, so I install that separately.
  • --skip-kamal because I generally deploy to Heroku. For the same reason, I also --skip-decrypted-diffs, since I prefer to use Heroku environment variables for credentials.

I want to look into the --template option, since that seems like a great way to add all the other defaults that I can’t configure from rails new, such as standardrb and rspec.

Discussion

Remove Nth Node From End of List

May 9, 2025

I occasionally solve algorithm questions on Leetcode as a fun exercise. I recently wrote about a dynamic programming question I solved, and I thought it would be fun to share another one. This time it’s a simple linked list question.

Arrive Meme - What does Big O Notation Mean?

Remove Nth Node From End of List

Given the head of a linked list, remove the nth node from the end of the list and return its head.

Example 1:
Input: head = [1,2,3,4,5], n = 2
Output: [1,2,3,5]

Example 1

Example 2:
Input: head = [1], n = 1
Output: []

Example 3:
Input: head = [1,2], n = 1
Output: [1]

I started off just creating a skeleton of the solution that allows me to run the examples and see the output.

class ListNode
  attr_accessor :val, :next
  def initialize(val = 0, _next = nil)
    @val = val
    @next = _next
  end
end

# @param {ListNode} head
# @param {Integer} n
# @return {ListNode}
def remove_nth_from_end(head, n)
  head
end

def print_list(head)
  result = []
  while head
    result << head.val
    head = head.next
  end
  pp result
end

print_list(remove_nth_from_end(ListNode.new(1, ListNode.new(2, ListNode.new(3, ListNode.new(4, ListNode.new(5))))), 2))
print_list(remove_nth_from_end(ListNode.new(1), 1))
print_list(remove_nth_from_end(ListNode.new(1, ListNode.new(2)), 1))

Now onto the actual solution. Since this is a linked list we can’t simply walk all the way to the end of the list and then backtrack. Instead I found it useful to think of the desired end state of the algorithm - we want to end up with a pointer pointing to the node before the nth node from the end of the list. So in the example of [1, 2, 3, 4, 5] we want a pointer at the 3 node, which is the third node from the end, in order to be able to do p.next = p.next.next.

The trickiest part of this algorithm is the edge cases - I got a bit stuck on how to solve for [1, 2] with n = 1 (the result being [1]) and n = 2 (the result being [2]). Then I realized that when the size of the list is the same as n, that is a special case where you return head.next. In all the other cases I can modify the list and return head.

# @param {ListNode} head
# @param {Integer} n
# @return {ListNode}
def remove_nth_from_end(head, n)
  p = head
  list_size = 0
  until p.nil?
    list_size += 1
    p = p.next
  end

  if n == list_size
    head.next
  else
    p = head
    (list_size - n - 1).times { p = p.next }
    p.next = p.next.next
    head
  end
end

I didn’t really enjoy doing this algorithm as much as the dynamic programming one. I’m doing the algorithms because it’s something I enjoyed doing when I was a student, and it helps to bring back some of the joy of programming for it’s own sake. Maybe I’ll stick to the ‘hard’ questions, or at least questions where a brute force answer is easy but an optimal one takes some creativity.

Discussion

Interacting with Shell Commands in Ruby

May 9, 2025

We often need to run shell commands from within Ruby code, and Ruby provides a number of ways to do that. It’s sligtly confusing at first - specifically because there are different ways of accomplishing the same thing. I found this useful flowchart on StackOverflow that helps you choose the appropriate option. If you’re looking for a detailed writeup of the different options I recommend reading 6 Ways to Run Shell Commands in Ruby by Nate Murray.

SubProcess Flowchart Ruby

One common mistake I’ve seen - and made myself - is to use string interpolation when passing arguments to a shell command. For example, in my recent post on Tweeting new Jekyll Posts I was using Ruby to interact with the git command. I could have written:

Bad
# Git add and commit (unsafe example)
commit_message = "Blogged: #{document.title} #{URI.join(site_config.fetch('url'), document.url)}"
`git add #{post_path}`
`git commit -m "#{commit_message}"`

This is a bad idea because

  1. Can break if it includes double quotes, ampersands, semicolons, etc.
  2. Can lead to command injection if the title includes malicious input.

We could get around (1) by using Shellwords.escape and you could argue I don’t have to worry about (2) because I control the input. However, best practice dictates that you should never interpolate any arguments into shell commands. Instead you should use system and pass the arguments as parameters.

Good
# Git add and commit
commit_message = "Blogged: #{document.title} #{URI.join(site_config.fetch('url'), document.url)}"
system('git', 'add', post_path)
system('git', 'commit', '-m', commit_message)

This principle doesn’t just apply to Ruby - I had the exact same issue with Java code I wrote recently. Instead of building the entire command:

Bad
String command = String.format(
    "pg_dump --dbname=%s --host=%s --port=%s --username=%s",
    connectionProperties.dbName(),
    connectionProperties.host(),
    connectionProperties.port(),
    connectionProperties.username()
);

ProcessBuilder processBuilder = new ProcessBuilder("bash", "-c", command);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();

Pass the arguments as parameters:

Good
List<String> pgDumpCommand =
    List.of(
        "pg_dump",
        "--dbname",
        connectionProperties.dbName(),
        "--host",
        connectionProperties.host(),
        "--port",
        connectionProperties.port().toString(),
        "--username",
        connectionProperties.username()
    );

ProcessBuilder processBuilder = new ProcessBuilder(
    Stream.of(pgDumpCommand, options).flatMap(Collection::stream).toList()
);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();

In this example I also needed to allow the consumers of this code (which I control) to pass additional options. In that case I would also suggest that you use an explicit whitelist/allowlist for accepted options.

Discussion

Fingerprinting Jekyll SASS Assets

May 7, 2025

As I’ve been updating the stylesheets on my blog, I ran into an issue with browser caching — changes to my CSS weren’t showing up right away. Since I’m serving assets through AWS CloudFront with a 7-day cache for non-HTML files, this behavior makes sense. While I could disable caching altogether, that feels like a blunt and amateur solution. Instead, I’m implementing asset fingerprinting to keep the performance benefits of caching while ensuring everyone always get the latest version of my styles.

I’m using the built-in jekyll-sass-converter plugin for Jekyll to compile my SASS files into CSS. Unfortunately it doesn’t offer any support for fingerprinting assets. I searched around and found the jekyll-minibundle which supports both minification and fingerprinting, but it doesn’t work with jekyll-sass-converter. I did stumble upon this gist by Yaroslav Markin which looked appealing - just a few lines of code and you have a hand-rolled digesting solution.

# frozen_string_literal: true

require 'digest'

module Jekyll
  # Jekyll assets cachebuster filter
  #
  # Place this file into `_plugins`.
  module CachebusterFilter
    # Usage example:
    #
    # {{ "/style.css" | cachebuster }}
    # {{ "/style.css" | cachebuster | absolute_url }}
    def cachebuster(filename)
      sha256 = Digest::SHA256.file(
        File.join(@context.registers[:site].dest, filename)
      )

      "#{filename}?#{sha256.hexdigest[0, 6]}"
    rescue StandardError
      # Return filename unmodified if file was not found
      filename
    end
  end
end

Liquid::Template.register_filter(Jekyll::CachebusterFilter)

I ran my site locally and it worked - my css file had a fingerprint appended.

<link rel="stylesheet" href="/css/main.css?b0763bce">

I pushed it to my staging site and it didn’t work - no fingerprint. Which must mean the filename wasn’t found. I then went through a few rounds of debugging with ChatGPT - adding Jekyll logging, etc - and finally concluded that ChatGPT’s initial direction (which I ignored) was correct - when the filter runs the compiled CSS file doesn’t exist yet. It works locally because the previous build had already generated the file.

A quick hacky fix was to run jekyll build twice in staging, which did work - but is obviously not a great solution. Instead I needed to either get access to the compiled CSS file or generate the digest from the source SASS files. I don’t think it’s possible to access the compiled CSS file - because it doesn’t necessarily exist yet - so instead I started to look at the jekyll-sass-converter code in more detail.

At a high level the filter is invoked on the output of the SASS converter - in my case, /css/main.css. It then needs to

  1. Find the source manifest file - in my case, /css/main.scss
  2. Find all referenced SASS files

Instead of trying to parse out the SASS files I opted to simply look at all the files in my /_sass directory. The plugin allows you to alter the source directory for the SASS files (and add others) so ideally I need to interact with the plugin directly and get the config that way - I didn’t want to duplicate the code to parse the plugin’s config. Luckily this is something supported by Jekyll.

# frozen_string_literal: true

require 'digest'

module Jekyll
  # Jekyll assets sass_digest filter
  module SassDigestFilter
    # Usage example:
    #
    # {{ "/style.css" | sass_digest }}
    # {{ "/style.css" | sass_digest | absolute_url }}
    def sass_digest(filename)
      site = @context.registers[:site]
      return site.data['sass_digest'][filename] if site.data.dig('sass_digest', filename)

      scss_file = site.in_source_dir("#{File.dirname(filename)}/#{File.basename(filename, '.css')}.scss")
      unless File.exist?(scss_file)
        Jekyll.logger.warn 'SassDigest:', "#{scss_file} does not exist"
        return filename
      end

      scss_converter = site.find_converter_instance(Jekyll::Converters::Scss)
      if scss_converter.nil?
        Jekyll.logger.warn 'SassDigest:', "#{Jekyll::Converters::Scss} converter not found"
        return filename
      end

      files = [site.in_source_dir(filename.sub(/\.css$/, '.scss'))]
      scss_converter.sass_load_paths.each do |path|
        Dir.glob("#{site.in_source_dir(path)}/**/*.scss") do |sass_file|
          files << sass_file
        end
      end

      site.data['sass_digest'] ||= {}
      site.data['sass_digest'][filename] = "#{filename}?#{digest(files)}"
    end

    private

    def digest(files)
      combined = files.sort.map { |f| File.read(f) }.join
      Digest::SHA256.hexdigest(combined)[0, 8]
    end
  end
end

Liquid::Template.register_filter(Jekyll::SassDigestFilter)

The last piece that I had to figure out was how to cache the computed digest within a single build - it turns out the site object has a data hash that makes this straightforward.

I’m using this for now, but I’m not confident that this is the correct approach. I want to see if it’s possible to add this to the jekyll-sass-converter plugin directly.

Discussion

Tweeting New Jekyll Posts From Github Actions - Part 2

May 5, 2025

I previously wrote about my experience attempting to use Github Actions to post a tweet every time I publish a new post on my self-hosted Jekyll/S3/Cloudfront blog. I managed to get to a working solution that was too complicated, so I’m trying another approach. I was following this post by Dave Brock where he described using the commit message as the entire tweet - so every commit message and git push is a tweet. I dismissed it as too simple, but now that I’ve seen how complicated the alternative is I’m going to try something similar.

Here is the new, simplified, workflow I envisioned:

  1. When I am ready to add a new post to git I run a Ruby script that generates the commit message
  2. When I deploy to product a step in the production deploy Github Action workflow tweets out the commit message

The first step is to write a Ruby script that generates the commit message. Identifying the post title and full url seems straightforward, but Jekyll allows you lots of flexibility around generating URLs, so I didn’t want to optimize for one specific use case. It turns out that I can instantiate the jekyll objects and tell it to parse the post itself.

#!/usr/bin/env ruby
# frozen_string_literal: true

require 'uri'
require 'jekyll'

# Get uncommitted post files
status_lines = `git status --porcelain _posts`.lines
new_posts = status_lines.select { |line| line.start_with?('??') }.map { |line| line.split.last }

if new_posts.empty?
  puts 'No new, uncommitted post found.'
  exit 0
end

# Initialize a site object from _config.yml
site_config = Jekyll.configuration({ quiet: true })
site = Jekyll::Site.new(site_config)

# Path to the new post
post_path = File.expand_path(new_posts.first)
document = Jekyll::Document.new(post_path, { site: site, collection: site.posts })
document.read

# Git add and commit
commit_message = "Blogged: #{document.title} #{URI.join(site_config.fetch('url'), document.url)}"
system('git', 'add', post_path)
system('git', 'commit', '-m', commit_message)

puts "Committed with message:\n#{commit_message}"

A nice part of this approach is that I can easily test the script locally.

A key piece of nuance is that not every commit is a new post - sometimes I’m editing an old post (fixing a typo, updating a link, etc), or I’m making a change to the site itself (Jekyll config, css, etc). I wanted to update my Github Action workflow to look for a commit message with the Blogged: prefix. Github Actions exposes the latest commit message via a variable - github.event.head_commit.message - but it’s not populated on manual workflows (which is what I’m using for a production deploy). So instead I need to manually fetch the latest git commit.

- name: Get latest commit message
  id: get_commit
  run: |
    msg=$(git log -1 --pretty=%B)
    echo "message=$msg" >> $GITHUB_OUTPUT

- name: Tweet the new post
  if: startsWith(steps.get_commit.outputs.message, 'Blogged:')
  uses: nearform-actions/github-action-notify-twitter@master
  with:
    message: ${{ steps.get_commit.outputs.message }}
    twitter-app-key: ${{ secrets.TWITTER_CONSUMER_API_KEY}}
    twitter-app-secret: ${{ secrets.TWITTER_CONSUMER_API_SECRET}}
    twitter-access-token: ${{ secrets.TWITTER_ACCESS_TOKEN }}
    twitter-access-token-secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}

This is a much simpler approach to what I had before, and it’s much easier to debug and predict what is going to happen. The only downside is that I need to be careful with my git commits before pushing to production - for example, if I fix a typo before pushing to production I need to be careful to amend the original commit.

Discussion