Skip to main content

Command Palette

Search for a command to run...

Sharing My Toys

How I'm hosting multiple Rails apps on one server to keep costs low for my side projects

Updated
16 min read
Sharing My Toys

Every now and then, I’ll come up with an idea for an app or code up a small experiment. I’ve considered sharing those publicly, but for any that are a full web app, I’d have to register a domain and spin up a new server for each one. Not only does that require significant time, it could also get expensive pretty quickly, and I don’t want to spend a bunch of money on my side projects.

Some of them I could set up on GitHub Pages if they’re just a JavaScript demo, which could be a good option, but I’d prefer to host them on my own website.

I’ve been working on a solution to this on and off for the past several months, and was recently able to finish up a first version. My hope is that reducing the friction and cost to getting these side projects out there will also help me maintain motivation to work on them.

This article will cover my current solution, including some things I tried along the way that didn’t work out, and where I might take this setup in the future.

The Goals

Going into this project, I knew:

  1. I want to build with Ruby on Rails. That’s my primary environment at work, and I don’t want to spend time learning a new framework. It also makes it easy to get something up and running quickly, which is a plus when I don’t have a lot of time to put towards these projects.

  2. I already have a domain registered (this one), so I want to deploy to subdomains of that. This also has the benefit that I don’t need to put much effort into naming any of these apps, at least initially.

  3. To keep hosting costs down, I only want to run a single, inexpensive server. If any app gets enough traffic that it needs a dedicated server, it’ll be time to split it out to its own domain.

  4. I want it to be easy to add a new app to the setup.

One other goal was to open source some of these apps and experiments. I’ve been working in software over ten years now, but I haven’t often shared my learning outside my company because nearly all my work is proprietary. I’ve benefited greatly from open source software, both by pulling it in as dependencies and from reading the source, and I hope others can benefit similarly from these projects. Part of the reason I started this blog was so that I could share what I’ve learned with the broader software community.

My initial thought was to create Rails engines as semi-isolated guest apps, and mount multiple of those within one host Rails app. After a good bit of work, that is the solution I ended up with, though there were many problems to solve along the way and a few dead ends.

Just give me the solution already!

You can check out the live app here, and find the code on GitHub. The GitHub repository is not exactly the same as what I have deployed, because I’m not sharing all of the apps publicly. It does have all the important bits, though. The README doesn’t go deep into the details of how everything works (that’s what this post is for), but it should be enough to get you started.

I’m calling this “Toybox” after the concept of Breakable Toys from the book Apprenticeship Patterns by Dave H. Hoover and Adewale Oshineye. I read that book early in my software development journey, and that pattern stood out to me at the time, enough that I still remember it now. For this initiative, I’m building small apps and experiments with no commitment to continue development. Some may eventually become full apps, while others may never get beyond a demo or proof of concept. The point is creating and sharing them.

I’ve created one public app initially: a simple reading tracker app that doesn’t feel like it needs to be a social network. Seriously, why are so many reading trackers also a social network?! The source is also available on GitHub. There are a few more features I’d like to build for that app, and I have ideas for a few other public apps. Both will come whenever I have enough free time to work on them.

How’s all of this work?

With that background out of the way, I’ll use the rest of this article to talk about the details of my setup.

Hosting

I’m running this on a $6/month DigitalOcean VPS (1 vCPU, 1 GB RAM, 25 GB disk), and I’m using the lowest tier (~$15/month) of their managed PostgreSQL database (1 vCPU, 1 GB RAM, 10 GB storage). The total cost works out to just over $20/month. I could get that lower if I ran my own database server, but I’m happy with it for now. It’s possible this could be managed on the $4/month server, but 512 MB RAM would be tight. Maybe if I wasn’t using Docker?

Deploy

I use Kamal 2.0 to build the Docker container and deploy the app. I found it has a pretty steep learning curve, particularly for anything more advanced, but once it’s set up it makes deploying easy. It does not handle server hardening, though. I followed some of DigitalOcean’s tutorials to lock everything down, including these steps:

  • Disabled password logins over SSH.

  • Created a non-root user for deploys and SSH access.

    • The deploy user can use sudo, but a password is required.

    • Disabled root as an SSH user.

    • Added the deploy user to the docker group so Kamal has the required permissions to deploy.

  • Blocked all incoming connections except HTTP, HTTPS, and SSH with ufw.

  • Ran system and package updates.

  • Enabled automatic updates with unattended-upgrades.

I initially had the guest app engines in a sibling folder of the host app, which didn’t work well with Kamal because it clones the git repository to a temp folder by default, though there is an option to use the current directory as the build context instead. However, that setup also caused challenges when building the Dockerfile, because Docker doesn’t let you pull in folders from a parent directory. I wanted the guest apps to be their own git repositories to make it easier to share them individually or split them out later.

I tried several different approaches: build in a parent folder, copy the engine folders into place temporarily, symlink the folders, and probably a few more and variations of each. Eventually, I realized that git submodules were the right tool for the job. That way, the engines are their own repositories, but I can still put them in a subfolder of the host app. Exactly what I needed!

While I was working on this setup, Kamal got a new feature to use a local Docker registry with an SSH tunnel to send the docker image to the server during the deploy. I switched to that soon after it came out. It’s worked great for my single-server deploy and saves on Docker registry costs.

Also in Kamal’s config, I set up all the app credentials as ENV variables (stored in 1Password). I prefer an ENV approach to checking an encrypted file into source control. I also set up a Valkey accessory as the Action Cable backend. I considered using Solid Cable, but had issues with that and removed it before I’d fully figured out the database setup, but stuck with the Valkey/Redis backend because it should scale better and be a little more efficient.

I made some minor customizations to the Dockerfile and build setup to read the Ruby version from the .ruby-version file and setup and install Node, but not much other than that. Most likely, there are some improvements and optimizations to be found there still, particularly around caching to speed up the build.

Guest Apps

Guest apps are Rails engines managed as git submodules. This introduces a little bit of friction to setting up an app, but nothing too crazy and it solves a lot of other problems with Kamal and the Dockerfile. Removing an engine takes several steps to remove the submodule, and I may create a script at some point to streamline that. I created a generator template that adds all the necessary integration points to get a guest app off the ground quickly.

Initially, I had the host app directly mounting the engines in config/routes.rb, but then 37Signals released Fizzy and I looked at their source code and saw they had an engine that registered an initializer to mount itself. I adopted that pattern and added a series of initializers to make the engines self-mount and self-configure.

That became the guiding principle for the guest app engines: if possible, all that should be required to set one up is to add the git submodule in the right folder (it’s a Rails app, after all, and Rails devs like convention over configuration). Other than the pesky package.json file, that is all that is needed.

The Gemfile is plain Ruby, so it can look in the right folder and add all the engines dynamically. I created a few helpers in lib/engine_list.rb so any Ruby files (or ERB for YAML config files) that need the list of engines can pull from a centralized location. There is one place for the JavaScript that needs to get the list of engines, but that has to have its own code.

I added a custom config option to the host app which facilitates the engine self-configuration: root_host, which in production is toybox.kylecoding.com, and in development is toybox.kylecoding.localhost:3000. Using localhost as a top-level domain (supported by all modern browsers as long as you add http:// to the beginning, and I think only Safari requires that) allows running with the subdomain setup in development.

I also had to set config.action_dispatch.tld_length = 2 since everything is a subdomain off of toybox.kylecoding.com, not just directly off of kylecoding.com.

JavaScript

Originally, I was trying to go without a JavaScript build, but landed on having a minimal build for a few reasons:

  • Rails doesn’t have built-in support for multiple separate importmaps. I found one article about how to set up isolated importmaps, and it involved copying a lot of Rails code. That didn’t seem like a good solution to me.

  • I could do a single importmap, but then the JS code gets all mixed together and I have to directly add guest app JS dependencies to the host app. Also not great.

  • If the engines each have a package.json file, and I can install them into the host app as JS packages, they can specify their own dependencies. Then the package manager (yarn in my case) can coordinate the versions of dependencies between each app so that they (hopefully) stay on the same version if multiple use the same dependency (reducing Docker image size). At the very least, I can have yarn deduplicate dependencies if I need to.

The biggest challenge is that I can’t make package.json dynamic. I looked at a few different options for JS package managers, and all of them had this limitation. I settled on Yarn 4 because it has a portal: protocol for installing packages from a local folder. Unlike the file: protocol, portal: references the local folder directly instead of copying it, which is perfect for this use.

To solve package.json being a static file, I created a script (bin/update_engine_node_modules) which adds and removes engines from package.json based on what’s in the engines/ folder. Other than running bundle install and yarn install, that is the one manual step that needs to happen when adding or removing an engine. At some point, I’ll probably create a script that combines all the steps, including creating the git submodule.

I used esbuild to bundle the JavaScript. It creates a separate entry point for the host app and each guest app, using the list of folders in engines/ (since I can write the config in a .js file).

The guest apps also define a constant with some additional information the host app needs to show a list of apps. This is just basic things like whether the engine should be public or not, a title and description, and a link to the source code.

Gemfile and YAML config files

The Gemfile is just a Ruby DSL, so I can dynamically add the guest app engines.

# Gemfile
require_relative "lib/engine_list"
Toybox::ENGINE_LIST.each do |engine|
  gem engine, path: File.expand_path(engine, Toybox::ENGINES_DIRECTORY)
end

All the YAML config files that need the list of engines support ERB, including Kamal’s config/deploy.yml, so I could make all of those dynamic, too.

# Sample from config/database.yml

# ERB still processes things in YAML comments.
# Putting this in a comment makes some editors' syntax highlighting happier.
# <% require_relative "../lib/engine_list" %>
default: &default
  adapter: postgresql
  encoding: unicode
  pool: 100

development:
  primary:
    <<: *default
    database: toybox_development
# <% Toybox::ENGINE_LIST.each do |engine_name| %>
  <%= engine_name %>:
    <<: *default
    database: toybox_<%= engine_name %>_development
    migrations_paths: db/<%= engine_name %>_migrate
# <% end %>

The same works for defining the hosts in config/deploy.yml (Kamal does not support automatic TLS certificate management for wildcard domains, so I need to specify each separately):

# Sample from config/deploy.yml

# <% require_relative "./lib/engine_list" %>
proxy:
  ssl: true
  hosts:
    - toybox.kylecoding.com
    # <% Toybox::ENGINE_LIST.each do |engine_name| %>
    - <%= engine_name.dasherize %>.toybox.kylecoding.com
    # <% end %>

Database

I’m using DigitalOcean’s managed PostgreSQL. I’d initially planned to prefix the table names for each guest app, but then I discovered that, unlike other managed PostgreSQL databases I’ve used in the past (Heroku), DigitalOcean’s offering is a managed PostgreSQL database cluster. The distinction is that DigitalOcean allows you to create multiple databases on a single database server, which is perfect for this use case. Once I figured that out, I abandoned the table name prefixes in favor of multiple databases. That makes the implementation cleaner in the short term, and should simplify splitting out one of the engines to its own app in the future.

It took me a while to get the multi-DB setup right. I started with just calling connects_to in the engine ApplicationRecord classes, then realized I needed Action Text and potentially Active Storage for at least one app, and that it would be good to have those isolated, too. I spent a good bit of time trying activerecord-tenanted before I realized that it assumes all databases have the same schema. In the end, I just used Rails’ built-in multi-DB and shard switching support, which activerecord-tenanted uses under the hood, so the time experimenting with that wasn’t wasted.

I ran bin/rails generate active_record:multi_db to bootstrap the config, then customized to suit my needs. In the host app, I set up the shard resolver to use the request subdomain and configured the available shards on ActiveRecord::Base so they would get picked up by the Action Text and Active Storage models.

# config/initializers/multi_db.rb
Rails.application.configure do
  config.active_record.shard_selector = { lock: true }
  # Select the database based on the app subdomain
  config.active_record.shard_resolver = ->(request) { request.subdomain.underscore }
end

# Configure all "shards" at the top level so Action Text and Active Storage tables can be isolated.
# The shard resolver above handles switching to the right database.
ActiveRecord::Base.connects_to shards: {
  **Toybox::ENGINE_LIST.each_with_object({}) do |engine_name, acc|
    name_symbol = engine_name.to_sym
    acc[name_symbol] = { writing: name_symbol, reading: name_symbol }
  end
}

Next, each engine ApplicationRecord class is configured to connect to a single shard. This may not be strictly necessary to run the app, but it makes interacting with the models in a Rails console much easier because they will automatically connect to the right database. This configuration is auto-generated by the template for new guest apps.

# Books guest app engine: app/models/books/application_record.rb
module Books
  class ApplicationRecord < ApplicationRecord
    self.abstract_class = true

    connects_to shards: {
      books: { writing: :books, reading: :books }
    }
  end
end

Host app

I tried to keep the host app relatively simple: one static page with some information on the project and a list of all the public guest apps (generated dynamically based on what guest app engines are present and the title/description provided by each engine). I don’t anticipate any major changes to the host app at this point, other than potentially handling the configuration for Active Job whenever I need it.

Testing

The host app doesn’t really have any tests at the moment, and I don’t plan to add any right now, since I don’t expect it to have a lot of changes, and it’s pretty simple.

Testing for the guest apps took a bit of effort to set up. I decided to stick with Minitest, although I’m more familiar with RSpec. I started by getting the tests from the authentication generator passing. For the most part, that was a matter of making sure everything (including fixture YAML files) was properly namespaced and the correct subdomain was set during the test.

For system tests, I had to set up a JS build within the engines, and make sure Slim was loaded for templating (you won’t convince me to use ERB). I also had to configure Capybara to use the correct subdomain and automatically add the port number. I didn’t find a good way to get the port number out of Capybara to configure the allowed hosts, so I disabled host authorization for test mode. I opted to use the Playwright driver, since I’ve had good experience with that in the past. It is the best I’ve tried for element interactions, and it works well with web components—much better than Selenium that fails pretty quickly when it has to deal with the shadow DOM. I don’t have any web components in my app right now, but they are my tool of choice when I need a JS-heavy interaction that’s more than what I want to do with Stimulus.

For the Books app, I focused on writing a few high-level system tests to get that ready to ship. It needs some more unit tests, which I’ll add as I’m building new features.

Terms of Use and Privacy Policies

As a final step before I started sharing links to toybox.kylecoding.com and the related guest apps, I wanted to get some basic terms of use and privacy policies in place. I looked around to see if there were any good templates, and found most of the ones that claimed to be “free” wanted you to pay as soon as you needed terms to cover user sign ups.

Since this is a side project that I don’t want to spend a bunch of money on, I turned to ChatGPT to generate those legal documents, which I think are good enough for now and better than having nothing. I created separate ones for the host app and the Books app, since the host app is just a static page and doesn’t have user accounts or user-entered data.

What’s next?

Eventually, I’ll need to figure out background jobs, and I’ll want to set up some basic analytics. I’ll probably also tune the log messages to be less verbose in production.

Copying migrations from the engines could use some attention. The bin/rails <engine_name>:install:migrations commands technically work, but I haven’t figured out how to get it to be aware of the multi-database setup and put the migrations in the correct folder.

I’ll be keeping an eye on Ruby::Box, introduced in Ruby 4. Once that’s more mature, maybe I can use it to run multiple full Rails apps with a single Rack config, while still keeping them isolated. That could be simpler and cleaner if it doesn’t require building each app as a Rails engine. Whether I switch to that or not will depend on how that impacts resource usage.

I’ve likely missed explaining some part of this setup. If you come across something that you’d like to know more about, feel free to leave a comment or start a discussion on one of the GitHub repositories. I’m happy to answer any questions when I have time.

I plan to keep developing the Books app, and I have a few JS experiments I’ve done that might be fun to share. I also have ideas for another app or two that I may add to this setup. As I move those forward, I will likely post about them here. Stay tuned!