Building Lean Docker Images for Rails Apps

Are you building Docker images for your Rails apps that take up too much unnecessary space? Learn how to keep your file sizes under control.

One of my favorite tools I use nearly daily is Docker. From helping me experiment, develop, test, and deploy my applications, it’s an indispensable part of what helps me do my work faster.

Much of my work with clients revolves around Ruby on Rails applications. In most instances, these are smaller startups with limited resources at hand. Beyond the scope I’ve been given as my project, part of my job is to ensure that the team can work as effectively as possible with these Rails apps, and Docker plays a massive role in helping me achieve that goal.

Benefits of Dockerizing a Ruby on Rails application

During development, Docker lets software engineers have a standardized environment to run their Rails app without worrying that their local system works differently from the rest of the architecture where the application will run. It’s not uncommon to have a Rails app work in one developer’s system while breaking on staging or production due to a slight difference between environments. Docker reduces those possibilities by allowing everyone to verify their work on the same playing field.

Docker also works great during the testing and deployment phases. Using standardized conditions means we can move the application through different stages of the product pipeline with minimum intervention. A typical use case for Docker is in continuous integration and deployment workflows. We can build a Docker image in CI, which runs a battery of automated tests. Upon success, we can use that same Docker image to deploy the app to production automatically.

Once in production, we can leverage Docker images to improve the reliability and scalability of our system. Ruby on Rails performance is a typical pain point on most teams, and these problems often surface in production when traffic surges suddenly appear. Sluggish app performance will lead to unhappy customers and, in some scenarios, can even bring the entire app crashing down. While the team fixes any underlying performance issues, we can mitigate any urgent problems using Docker to spin up additional app instances to increase the system’s capacity.

Docker isn’t necessary for software development teams to create high-quality Rails apps. Still, it provides many benefits that can help organizations bring their products to life quicker and easier.

Beware of the Docker bloat

Once you know how Docker works, creating Docker images for Rails applications is a straightforward process. However, this ease of use also makes it easy to build Docker images with a large file size. Since Docker images contain an isolated operating system and the application’s dependencies alongside the application itself, it can quickly lead to bloated images that can create issues down the road.

Depending on how a Docker image gets built, a larger image can demand more resources on a system. Larger images often take a longer time to build. After building, not only does the image require more disk space, but it can also require more CPU and memory to run. When trying to save on server costs, we often need to ensure we’re making the most out of the hardware we have available, and having a larger Docker image doesn’t help.

The larger file size also slows down or even restricts distribution amongst the team. Not everyone in the world has a gigabit fiber Internet connection, and downloading Docker images over 1 gigabyte in size isn’t feasible all the time. Some team members might not even be able to download the image at all due to metered bandwidth.

Some organizations don’t pay close attention to these and instead opt to throw money at the problem by adding more system resources like migrating to larger servers or spinning up new ones. However, not every company can do this, so we need to be mindful of how we build a Docker image so it doesn’t get out of control.

Dockerizing a Rails app with the smallest footprint possible

Let’s go through the process of creating a Docker image that will hold a Rails application. The Docker image will contain an operating system and all of the required dependencies needed to build the app and assets in the application. For the examples in this article, we’ll use an open-source Rails application I created called Airport Gap, a tool to help testers practice automating API tests.

Airport Gap is a standard Rails 7.0 application containing a few data models and controller actions. Building the application will require only a few steps since it’s a relatively small application that does little. At a high level, our Docker image will need the following prerequisites:

  • Of course, we need the version of Ruby we use for the application and Bundler to set up Ruby gems.
  • Tools to build native extensions for some of the included Ruby gems.
  • The Node.js runtime to handle JavaScript dependencies.
  • The Yarn package manager to install the JavaScript dependencies.
  • Since we’re using PostgreSQL as our database, we’ll also need its client library installed.

Remember .dockerignore before creating a Dockerfile

Now that we have a list of what we need, we can start by building our Docker image. However, the purpose of our exercise in this article is to create the smallest possible Docker image for the Rails application. The first step before doing this is to ensure we have a .dockerignore file in place.

The .dockerignore file lets us specify which files and directories in our application we don’t want to include in our Docker image. The primary purpose of this file is to improve build times and end up with a smaller image by excluding what we don’t need to run our application. It also helps with security since we want to exclude files or directories containing sensitive data in our final Docker image.

Some typical files and directories to ignore in Rails applications are:

  • /log/, which contains development and test logs.
  • /tmp/, which contains temporary files.
  • /config/master.key, which contains the master key that decrypts your credentials file.

While we’re deleting some content like log files and temporary files not needed in the image, we must keep those directories since Rails apps need them. We can choose to keep some of the default .keep files that new Rails applications place in these directories to ensure the directories exist in the finalized Docker image.

The files and directories you will want to exclude depend on your project. Here’s the .dockerignore file we’re using for the Airport Gap application:

/.git
/log/*
/tmp/*
/tmp/pids/*
!/log/.keep
!/tmp/.keep
!/tmp/pids/
!/tmp/pids/.keep
/config/master.key
/.env
/.DS_STORE
/public/packs
/public/packs-test
/node_modules
/yarn-error.log
/yarn-debug.log*
/.yarn-integrity
/app/assets/builds/*
!/app/assets/builds/.keep

Which Docker image to use?

For most Rails applications, you’ll want to use the official Ruby Docker image as your base to build off since it contains Ruby along with many other tools like Bundler pre-installed. Docker images are typically scoped by version, so you can find a Docker image with the specific version of Ruby you need. In our example, we’ll use a Docker image containing Ruby 3.2.2, the version specified in the application.

You can find Docker images using different underlying versions of the Debian Linux operating system, like bullseye and buster. Unless you need a specific version of the OS (like, for instance, you need a particular library unavailable in a newer version), it’s usually safe to choose any Debian image variant.

Besides the different images by version and operating system, most official Docker images contain additional variants:

  • Standard: The standard variant includes many common Debian packages pre-installed. This image makes it easier to build our application but has the largest file size due to everything it contains.
  • slim: The slim variant mainly contains the Debian packages that Ruby requires. Since it has very few pre-installed packages, the image file size is significantly smaller, but it comes at the cost of convenience.
  • alpine: The alpine variant is an image based on the Alpine Linux distribution, designed to be lightweight. Since Alpine uses different underlying libraries compared to a typical Linux distro, it requires the most work to get up and running.

This article will explore packaging the Airport Gap Rails application using the standard, slim, and alpine Ruby 3.2.2 images and see how each compares.

Using the standard Ruby image

First, we’ll use the standard Ruby 3.2.2 Docker image, which has a file size of 892 megabytes. We’re already starting with a rather big file size using this base image. Once we include our application code and all its libraries and dependencies, We should expect the file size to clear the 1 gigabyte mark easily.

Let’s create a Dockerfile that sets up the Airport Gap application to run within a Docker container. I won’t go through the details of each command here, but I’ll mention why we include some aspects in this file.

FROM ruby:3.2.2
WORKDIR /app

# Set a random secret key base so we can precompile assets.
ENV SECRET_KEY_BASE airport_gap_secret_key_base

# Set up Node.js and Yarn package repositories.
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

# Install necessary packages to build gems and assets.
RUN apt-get update && \
  apt-get install -y --no-install-recommends \
    nodejs \
    yarn && \
  rm -rf /var/lib/apt/lists/*

# Install gems.
COPY Gemfile Gemfile.lock /app/
RUN bundle install --jobs 4 --retry 3

# Install JavaScript dependencies to precompile assets.
COPY package.json yarn.lock /app/
RUN yarn install

# Precompile app assets as the final step.
COPY . /app/
RUN bin/rails assets:precompile

EXPOSE 3000
CMD ["bundle", "exec", "rails", "s", "-p", "3000"]

I’m setting the SECRET_KEY_BASE environment variable for the image with a dummy value. While this value is typically overridden while running the Docker image under a real environment, we include it here since we’re precompiling the application’s assets later in the Dockerfile, and Rails will complain if it doesn’t have this value set.

Next, we’re setting up the package repositories that contain the Node.js runtime and the Yarn package manager. The official Ruby Docker image doesn’t include these tools installed by default, and we need them to fetch the application’s JavaScript dependencies and build the assets.

For Node.js, we’re setting up the NodeSource Node.js Binary Distributions for Node.js version 18, which contains the current LTS version as of this writing. For Yarn, while we can install the package manager after installing Node.js, we’re opting to set up the package repository for Debian. Once the repos are in place, we can then use Debian’s apt-get install command to install them. We’re also cleaning up the package repository’s cache to reduce the final size of the image further.

The following steps copy the Gemfile and Gemfile.lock files into our workspace directory and install the application’s gems using Bundler, already included in the base image. We also copy the package.json and yarn.lock files and install JavaScript dependencies using the newly-installed Yarn package manager. We copy these files first into the image to take advantage of Docker’s build cache mechanism.

The way build caching works is that if Docker doesn’t detect anything changed in one of the steps, it uses a cached layer from a previous build in the same system. The first time we build the image for our Rails application, Docker will download and install gems and JavaScript dependencies defined for the app. The next time we go through this build process on the same system, Docker will look at these files first, and if nothing changes in their contents, it will use the cached layers instead of rerunning the instructions. This caching significantly improves build times by skipping the downloads and installation of these files.

The remainder of the Dockerfile handles copying the entire directory into the image (minus the files specified in our .dockerignore file), exposes our desired port within the Docker environment, and sets the default command to spin up the Rails application. With everything in place, we can build the image for our Rails app using docker build -t <image_name>:<tag> where <image_name> is the name of the image you want to use, like airport-gap, and the tag is a way to set a reference, usually latest.

In a few minutes, we’ll have a Docker image containing the Airport Gap application ready to run in a containerized environment. So that’s the end result when it comes to file size? We started with 892 megabytes from the base image. After all the package setup, gem installation, and asset precompile, we end up with a Docker image that weighs a whopping 1.53 gigabytes.

Why did our built image end up nearly double the size of the base image? The total file size of the Airport Gap application copied over to the image is less than 20 megabytes. However, all the dependencies we had to install to get the gems and JavaScript packages in place took up the bulk of the extra space in the final build image.

We don’t need any of these dependencies to run the application. As part of the image build process, we can add more steps to remove those dependencies to slim things down. But a better way to manage this is with multi-stage builds.

Multi-stage builds with Docker

In our first attempt, we ran all our steps using the same base image in the FROM instruction, which led to the final image containing everything we installed. However, Docker also allows us to use different FROM instructions to specify additional images in the same Dockerfile. Each base image is known as a stage. When moving from one stage to the next, you can copy files from previous stages, and the final build image only contains whatever remains in the final stage.

We can take advantage of multi-stage builds in Rails applications by separating the stages according to different dependencies. For the Airport Gap application, we can split our Docker build into two stages:

  • The first stage will handle installing the application’s gems and JavaScript packages, as well as precompiling the assets.
  • The second stage can copy the application files from the first stage without the unnecessary dependencies to run.

Since the bulk of the final image size in our previous example comes from dependencies, a multi-stage build should keep the file size as small as possible since it won’t install any of those dependencies in the second and final stage. Let’s see how we can convert our Dockerfile from a single stage to a multi-stage build:

#####################################################################
# Stage 1: Install gems and precompile assets.
#####################################################################
FROM ruby:3.2.2 AS build
WORKDIR /app

# Set a random secret key base so we can precompile assets.
ENV SECRET_KEY_BASE airport_gap_secret_key_base

# Set up Node.js and Yarn package repositories.
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

# Install necessary packages to build gems and assets.
RUN apt-get update && \
  apt-get install -y --no-install-recommends \
    nodejs \
    yarn

# Install gems into the vendor/bundle directory in the workspace.
COPY Gemfile Gemfile.lock /app/
RUN bundle config set --local path "vendor/bundle" && \
  bundle install --jobs 4 --retry 3

# Install JavaScript dependencies to precompile assets.
COPY package.json yarn.lock /app/
RUN yarn install

# Precompile app assets as the final step.
COPY . /app/
RUN bin/rails assets:precompile

#####################################################################
# Stage 2: Copy gems and assets from build stage and finalize image.
#####################################################################
FROM ruby:3.2.2
WORKDIR /app

# Make sure Bundler knows where we're placing our gems coming from
# the build stage.
RUN bundle config set --local path "vendor/bundle"

# Copy everything from the build stage, including gems and precompiled assets.
COPY --from=build /app /app/

EXPOSE 3000
CMD ["bundle", "exec", "rails", "s", "-p", "3000"]

This Dockerfile is a bit longer but contains similar build instructions as before, with a few key differences. First, we’re signaling that this is a multi-stage build using multiple FROM steps. Both stages use the official Ruby Docker image. However, in the first FROM step, we’ve added AS build to the instruction, which names this particular stage. Naming your build stages is optional, but it helps us understand when we use different stages in subsequent steps.

The remainder of the build steps during the first stage remains the same as our single-stage build. We’re setting up all the dependencies needed to install the application’s gems and JavaScript packages, and we wrap up the stage by precompiling assets. The first stage will contain everything we need to get the application up and running, so we can move along to the second stage.

One difference with how we’re installing dependencies here is that we’re setting a Bundler configuration setting to indicate we want to install the gems into the vendor/bundle directory of the current workspace (bundle config set --local path "vendor/bundle). By default, Bundler installs gems outside of the application directory. But we’ll want to keep them in our application to easily copy them in the next stage, which we’ll see below.

The second stage contains fewer steps since we don’t need to deal with setting up any dependencies. As we did in the first stage, we’ll need to set up Bundler once again to specify the location of the application’s gems. The critical part of this stage is the COPY step.

In our first attempt, we copied all files from the current application directory into the Docker image after installing gems and precompiling assets. All those files are set up in the build stage this time, so we just need to copy the directory over to the final stage. The --from=build flag tells Docker to take these files from an earlier stage named build instead of copying them from the current directory. All the gems and precompiled assets will come along in the process, so the final build image won’t contain any previously installed dependencies.

We wrap up the final stage by exposing the port and setting the default command for starting the Rails app as we did before. When building the image using docker build -t <image_name>:<tag>, it’ll show each stage getting built, with the resulting image containing the Airport Gap application ready to run, precisely as our first example.

Does the multi-stage build make a difference? In our scenario, it makes a significant difference: the resulting Docker image comes in at 996 megabytes, about a 35% decrease compared to the first pass. The image is just over 100 megabytes larger than the base image size since it only contains the Airport Gap application code, the gems needed to run the application, and the precompiled assets. We didn’t copy all other dependencies that were only necessary to build the application, keeping the file size to a minimum.

However, this second pass at building a Docker image weighs close to one gigabyte, which is still pretty heavy. We can do much better by looking at one of the variants mentioned earlier in this article. Let’s try one out to see the differences in the build process and the final image size.

Using the slim variant

As the name suggests, the slim variant of the official Ruby Docker image is a slimmed-down edition of the same base image. As mentioned in this article, the image comes with what Ruby needs to run in Debian pre-installed and not much beyond that. This results in a smaller file size but will require us to add additional dependencies since they won’t be in the image from the start.

Using the slim image variant for Ruby 3.2.2, we’re starting with a file size of 175 megabytes. We already have an 80% reduction in file size right off the bat compared to the default base image. However, we’ll need to install additional dependencies in the underlying operating system, so we’ll likely have extra weight in the final build after copying the application’s files.

The build steps using the slim variant are the same as our default image since it still uses the same operating system. However, we’ll have to spend some more time upfront figuring out which packages are missing to install dependencies and run the Airport Gap application. After some trial and error, let’s update our Dockerfile, going straight for the multi-stage build.

# #####################################################################
# # Stage 1: Install gems and precompile assets.
# #####################################################################
FROM ruby:3.2.2-slim AS build
WORKDIR /app

# Set a random secret key base so we can precompile assets.
ENV SECRET_KEY_BASE airport_gap_secret_key_base

# Install necessary dependencies to set up Node.js and Yarn repos.
RUN apt-get update && \
  apt-get install -y --no-install-recommends \
  curl \
  gnupg \
  build-essential \
  libpq-dev

# Set up Node.js and Yarn package repositories.
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

# Install necessary packages to build gems and assets.
RUN apt-get update && \
  apt-get install -y --no-install-recommends \
  nodejs \
  yarn

# Install gems into the vendor/bundle directory in the workspace.
COPY Gemfile Gemfile.lock /app/
RUN bundle config set --local path "vendor/bundle" && \
  bundle install --jobs 4 --retry 3

# Install JavaScript dependencies to precompile assets.
COPY package.json yarn.lock /app/
RUN yarn install

# Precompile app assets as the final step.
COPY . /app/
RUN bin/rails assets:precompile

#####################################################################
# Stage 2: Copy gems and assets from build stage and finalize image.
#####################################################################
FROM ruby:3.2.2-slim
WORKDIR /app

# Install necessary dependencies required to run the Rails application.
RUN apt-get update && \
  apt-get install -y --no-install-recommends \
  libpq-dev \
  curl && \
  rm -rf /var/lib/apt/lists/*

# Make sure Bundler knows where we're placing our gems coming from
# the build stage.
RUN bundle config set --local path "vendor/bundle"

# Copy everything from the build stage, including gems and precompiled assets.
COPY --from=build /app /app/

EXPOSE 3000
CMD ["bundle", "exec", "rails", "s", "-p", "3000"]

You may notice this Dockerfile is a bit lengthier than our previous multi-stage build file. First, we added an extra step in the build stage to install some missing dependencies to set up the Node.js and Yarn package repositories, which we didn’t have to do previously since these packages were already part of the default image. Once we install those packages, the remainder of the build stage is the same as before.

When we arrive at the second stage of the multi-stage build, everything is almost identical, except we have to install a few additional packages in the operating system (libpq-dev and curl) since the Airport Gap application needs them present during runtime. As with the build stage, the default image already has these packages pre-installed, but the slim variant doesn’t.

After these additional instructions, how does the final image built on the slim variant affect the file size? After building our image using this Dockerfile, we have a file size of 283 megabytes. That’s a significant reduction of 71% over the final file size compared to using the default image. For just a few modifications in our Dockerfile, it’s a tremendous improvement to help with any workflows that need to download this image.

While seeing such an improvement is excellent, don’t fall into the trap of immediately going for the slim variant to build Docker images for your Rails applications. Since you’ll have fewer packages pre-installed with this variant, you’ll have to spend much more time building, testing, and maintaining your final image as the application changes. Compared to using the default image as your base, using the slim image will take considerable extra time to get your Dockerfile working for your needs. It’s a caveat you must remember for the long-term availability of the image.

Although seeing the file size of our Docker image comes down to a comfortable level, let’s test out an even slimmer version of the official Ruby Docker images to see how low we can go.

Using the alpine variant

The alpine variant is an official Ruby Docker image based on Alpine Linux, a more secure and lighter version of Linux that we can use to package up applications in a container. It’s considered a “minimalist” Linux distribution, containing only the bare minimum for Linux and smaller essential libraries. Regarding file size, none of the other Ruby Docker images comes close. The current version of the alpine variant for Ruby 3.2.2 comes in at 74.2 megabytes. For those keeping track, that’s 57% smaller than the slim variant and a massive 92% smaller than the default image.

As you might expect, we must make a few tradeoffs if we choose the alpine variant. When we used the slim variant to build the Docker image for the Airport Gap application, we had to go through some trial and error to figure out which packages we needed to complete a successful build. With the alpine variant, there’s even more tinkering required due to the fundamental differences in the underlying Linux distribution. One of the reasons why many developers don’t use this variant is that it takes a significant amount of time to get their applications working well.

The modifications to our Dockerfile using the alpine variant will look different compared to moving from the default image to the slim variant due to the distinct underlying operating system. Alpine Linux has its own package manager called Alpine Package Keeper, which works similarly to Apt and other Linux package management tools. The packages available differ from Debian’s package manager, so it takes some work to get the correct ones needed to build and run our applications.

One benefit of using the alpine variant with modern Rails applications is that Alpine Package Keeper already contains Node.js and Yarn, so we don’t need to set up the package repositories as we did previously. On the other hand, we have a few packages that typically come with most distributions that aren’t present by default in Alpine Linux, such as tzdata for time zone data, that we need to install ourselves.

Besides the different package manager and dependencies we need to install, the rest of the build instructions for bundling gems, installing JavaScript dependencies, and copying files from one stage to the next are identical. Let’s see how we can set up our Dockerfile using the alpine variant:

#####################################################################
# Stage 1: Install gems and precompile assets.
#####################################################################
FROM ruby:3.2.2-alpine AS build
WORKDIR /app

# Set a random secret key base so we can precompile assets.
ENV SECRET_KEY_BASE airport_gap_secret_key_base

# Install necessary packages to build gems and assets.
RUN apk add --no-cache \
  nodejs \
  yarn \
  tzdata \
  postgresql-dev \
  build-base

# Install gems into the vendor/bundle directory in the workspace.
COPY Gemfile Gemfile.lock /app/
RUN bundle config set --local path "vendor/bundle" && \
  bundle install --jobs 4 --retry 3

# Install JavaScript dependencies to precompile assets.
COPY package.json yarn.lock /app/
RUN yarn install

# Precompile app assets as the final step.
COPY . /app/
RUN bin/rails assets:precompile

#####################################################################
# Stage 2: Copy gems and assets from build stage and finalize image.
#####################################################################
FROM ruby:3.2.2-alpine
WORKDIR /app

# Install necessary dependencies required to run the Rails application.
RUN apk add --no-cache \
  tzdata \
  postgresql

# Make sure Bundler knows where we're placing our gems coming from
# the build stage.
RUN bundle config set --local path "vendor/bundle"

# Copy everything from the build stage, including gems and precompiled assets.
COPY --from=build /app /app/

EXPOSE 3000
CMD ["bundle", "exec", "rails", "s", "-p", "3000"]

As mentioned above, we use a different package manager (apk) to install the dependencies for the different stages. We must also install extra packages in the build stage and run the Airport Gap app in the final build. Beyond those updates, the remainder of the build steps does the same as with the other image variants we used in this article.

With these changes, how did we end up regarding the image file size? As expected, we reached the smallest Docker footprint using the alpine variant, with our final file size clocking in at 186 megabytes. That’s an 81% decrease compared to the default image’s multi-stage build and 34% smaller than when using the slim variant. With the current official Ruby Docker images, you can’t go lower than this size in our scenario.

The same caveats mentioned when using the slim variant also apply to using the alpine variant, and often even more so. There’s a good chance you or your team are more accustomed to using Debian and similar operating systems and probably have never touched Alpine Linux. There’s a bit of a learning curve to know what you need to get your application running. Also, since there are some fundamental changes in how Alpine Linux works, you may experience bugs that aren’t present in other Linux distributions.

Which Ruby Docker image should you use?

As seen throughout this article, choosing which image you should use for your Rails applications depends on your use case. Each choice boils down to time and hardware restraints for your project. The following rules of thumb can help you make a quick decision:

  • If you want to get your Rails application packaged up and running with minimal fuss and don’t care about storage or bandwidth limitations, the default Docker image will serve you best since it has the most pre-installed packages, and you don’t need to spend too much time on getting everything set up.
  • If you have specific requirements to build small images due to storage or bandwidth constraints and have plenty of time to test running your app in a container, the alpine image variant is the best choice since it will yield the smallest possible Docker image at the cost of requiring the most setup time.
  • If you don’t have strict file size requirements but don’t want to go overboard with the final image size, the slim variant is an excellent middle ground since it will give you significant storage savings without spending much time getting things working.

In my projects, I use the default Ruby Docker image when packaging my Rails applications for the first time to get them working quickly and with few issues. Once I have that working and I get time to refactor my Dockerfile, I try to use the slim variant since a smaller file size helps speed up some workflows (like continuous integration). I rarely use the alpine variant since, in most of my scenarios, the file savings aren’t worth the effort spent on making sure it works.

Whichever path you take, using Docker as part of your workflow will help you deliver high-quality applications faster and with fewer potential issues. It’s a tool you’ll want to leverage for your projects and across your organization.


If you need an experienced software engineer to help you with your Ruby on Rails applications, your Docker implementations, or other DevOps services, let’s talk. With 19 years of experience working with various startups, I can guide you through the complexities of modern software development and infrastructure to get the most out of your efforts. I’d love to help you build and maintain reliable, scalable, and secure applications from development to deployment. Contact me today to learn more about my services.

More articles you might enjoy

Article cover for Deploy Your Rails Applications the Easy Way With Kamal
Rails
Deploy Your Rails Applications the Easy Way With Kamal

Kamal is a new deployment tool that makes it easy to deploy your web applications to any server. Is it a good choice for you?

Article cover for Distributing Docker Images for Rails Apps With GitHub Actions
Rails
Distributing Docker Images for Rails Apps With GitHub Actions

Learn how to automatically build and distribute Docker images for your Rails apps and streamline your development, testing, and deployment workflows.

Article cover for 20 Lessons Learned From 20 Years in Tech: Part 2
Tech Career
20 Lessons Learned From 20 Years in Tech: Part 2

More reflections and lessons learned from a 20-year journey in tech to help guide you on your own path through the industry.

About the author

Hi, my name is Dennis! As a freelancer and consultant, I work with tech organizations worldwide to help them build effective, high-quality software. It's my mission to help these companies get their idea off the ground quickly and in the right way for the long haul.

For over 19 years, I've worked with startups and other tech companies across the globe to help them successfully build effective, high-quality software. My experience comes from working with early-stage companies in New York City, San Francisco, Tokyo, and remotely with dozens of organizations around the world.

My main areas of focus are full-stack web development, test automation, and DevOps. I love sharing my thoughts and expertise around test automation on my blog, Dev Tester, and have written a book on the same topic.

Dennis Martinez - Photo
Learn more about my work Schedule a call with me today