Dockerizing and Orchestrating Legacy Ruby Systems on Modern DigitalOcean Infrastructure
Assessing Legacy Ruby Application Dependencies
Before embarking on containerization, a thorough audit of the legacy Ruby application’s dependencies is paramount. This involves identifying not only Ruby gems but also system-level libraries, external services, and specific environment variables that the application relies upon. For older Rails applications, this often means dealing with deprecated gems, unsupported Ruby versions, and implicit assumptions about the operating system environment.
A common pitfall is assuming a direct mapping from the existing server environment to a container. Legacy systems might have been installed via package managers (apt, yum) or even compiled from source. These dependencies need to be explicitly declared and managed within the Dockerfile. Tools like bundle outdated and manual inspection of Gemfile.lock are starting points. For system dependencies, consult the application’s deployment documentation or perform runtime analysis.
Crafting the Dockerfile for Ruby Applications
The Dockerfile is the blueprint for your container image. For legacy Ruby applications, it’s crucial to select a base image that supports the required Ruby version. If the application uses an older, unsupported Ruby version (e.g., Ruby 1.8, 1.9), consider using a specific version of an official Ruby image or even a custom-built image based on a compatible OS distribution (like Debian Stable or Ubuntu LTS). For more modern Ruby versions (2.x, 3.x), the official Ruby images are generally sufficient.
Here’s a sample Dockerfile demonstrating best practices for a hypothetical Rails 3.x application:
# Use a base image with a compatible Ruby version.
# For older Ruby versions, you might need to build from source or use a specific OS image.
# Example for Ruby 2.3.x:
FROM ruby:2.3.7-slim
# Set environment variables to prevent interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install essential build tools and system dependencies.
# This is highly application-specific. Common examples include:
# - libpq-dev for PostgreSQL
# - libsqlite3-dev for SQLite
# - imagemagick for image processing
# - nodejs and yarn for asset compilation (if not handled separately)
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
build-essential \
git \
libpq-dev \
libsqlite3-dev \
imagemagick \
nodejs \
yarn \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory inside the container
WORKDIR /app
# Copy the Gemfile and Gemfile.lock first to leverage Docker's layer caching.
# This ensures that if only application code changes, gems are not reinstalled.
COPY Gemfile Gemfile.lock ./
# Install gems. Use a specific bundler version if required by the app.
# Consider using a multi-stage build to reduce final image size by not including build tools.
RUN bundle install --jobs $(nproc) --retry 3
# Copy the rest of the application code
COPY . .
# Precompile assets if using Rails asset pipeline.
# This can be done in a separate stage for smaller production images.
RUN bundle exec rails assets:precompile
# Expose the port the application will run on.
EXPOSE 3000
# Define the command to run the application.
# For development, you might use 'rails server -b 0.0.0.0'.
# For production, consider using a production-ready server like Puma or Unicorn.
# Example using Puma:
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
Optimizing for Production: Multi-Stage Builds and Asset Compilation
Production Docker images should be as lean as possible. Multi-stage builds are essential for this. The build stage can install all necessary development tools and compile assets, while the final stage copies only the necessary artifacts from the build stage, resulting in a smaller, more secure image.
# Stage 1: Build stage
FROM ruby:2.3.7-slim AS builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
build-essential \
git \
libpq-dev \
libsqlite3-dev \
imagemagick \
nodejs \
yarn \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs $(nproc) --retry 3
COPY . .
RUN bundle exec rails assets:precompile
# Stage 2: Production stage
FROM ruby:2.3.7-slim
# Install only runtime dependencies.
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy gems from the builder stage
COPY --from=builder /usr/local/bundle /usr/local/bundle
# Copy application code and precompiled assets from the builder stage
COPY --from=builder /app /app
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
Orchestration with Docker Compose on DigitalOcean
For orchestrating multiple services (e.g., your Ruby app, a database, Redis, Nginx), Docker Compose is an excellent choice, especially for smaller to medium-sized deployments or development environments. DigitalOcean’s Managed Databases and App Platform offer integrated solutions, but for maximum control, running Docker Compose on a DigitalOcean Droplet is a common pattern.
First, ensure Docker and Docker Compose are installed on your DigitalOcean Droplet. You can typically install Docker CE from the official Docker repository and Docker Compose via its release page.
# Install Docker (example for Ubuntu)
sudo apt-get update
sudo apt-get install \
ca-certificates \
curl \
gnupg \
lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Verify installation
docker --version
docker compose version
Next, create a docker-compose.yml file to define your services. This example assumes a Rails app, PostgreSQL, and Redis.
version: '3.8'
services:
db:
image: postgres:13
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
POSTGRES_USER: &user your_db_user
POSTGRES_PASSWORD: &password your_db_password
POSTGRES_DB: &dbname your_db_name
ports:
- "5432:5432" # Only for local development/debugging, remove for production
redis:
image: redis:6
ports:
- "6379:6379" # Only for local development/debugging, remove for production
app:
build: . # Assumes Dockerfile is in the current directory
command: bundle exec puma -C config/puma.rb
volumes:
- .:/app # Mount application code for development, remove for production
ports:
- "3000:3000"
depends_on:
- db
- redis
environment:
RAILS_ENV: production
DATABASE_URL: postgresql://&user:&password@db:5432/&dbname
REDIS_URL: redis://redis:6379/0
# Add any other necessary environment variables
volumes:
postgres_data:
To run this, navigate to the directory containing your Dockerfile and docker-compose.yml and execute:
docker compose up -d
For production deployments on DigitalOcean, you would typically build the Docker image and push it to a container registry (like Docker Hub or DigitalOcean’s Container Registry). Then, your docker-compose.yml would reference the image from the registry instead of using build: .. You would also remove volume mounts for application code and potentially expose ports only to internal networks or via a load balancer.
Leveraging DigitalOcean App Platform for Managed Containerization
DigitalOcean’s App Platform offers a higher level of abstraction, simplifying the deployment and management of containerized applications. It can build and deploy directly from a Git repository or from a container registry. For legacy Ruby apps, this means you can point the App Platform to your Git repo, and it will automatically detect the Ruby application (or you can specify it), build the Docker image (using its internal buildpacks or your provided Dockerfile), and deploy it.
When using App Platform:
- Build Source: Choose between Git repository or Container Registry. For legacy apps, Git is often simpler if you’re already versioning your code there.
- Build Command: If App Platform’s auto-detection fails or you need specific build steps (like
bundle install --without development test), you can specify custom build commands. - Dockerfile: You can provide your own
Dockerfilefor complete control over the image build process. This is often necessary for complex legacy applications with specific system dependencies. - Environment Variables: Configure database credentials, API keys, and other settings directly within the App Platform’s environment variable section.
- Databases: Integrate with DigitalOcean Managed Databases (PostgreSQL, MySQL, Redis) directly through the App Platform’s database add-on feature. This eliminates the need to manage database containers yourself.
The App Platform handles scaling, load balancing, and SSL termination automatically. For legacy applications, ensure your Dockerfile is robust and that all runtime dependencies are correctly installed. The application should listen on 0.0.0.0 and the port specified by the PORT environment variable (which App Platform injects).
# Example modification to Dockerfile for App Platform
# ... (previous build stage if using multi-stage)
# Production stage
FROM ruby:2.3.7-slim
# Install only runtime dependencies.
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy gems and application code from builder stage if using multi-stage
# COPY --from=builder /usr/local/bundle /usr/local/bundle
# COPY --from=builder /app /app
# If not using multi-stage, copy gems and code here
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs $(nproc) --retry 3 --without development test
COPY . .
# Precompile assets if needed and not done in a build stage
# RUN bundle exec rails assets:precompile
# App Platform injects the PORT environment variable
EXPOSE $PORT
# Use a production-ready server, listening on 0.0.0.0
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
Database Migrations and Background Jobs
Managing database migrations and background job workers requires careful consideration in a containerized environment. For migrations, you can execute them as part of the application startup process, but this can lead to race conditions if multiple instances start simultaneously. A more robust approach is to run migrations as a separate one-off container task.
Using Docker Compose, you can run migrations like this:
docker compose run --rm app bundle exec rails db:migrate
On DigitalOcean App Platform, you can define “Jobs” which are one-off tasks that run to completion. You would configure a migration job that uses your application’s image and executes the migration command.
Background job workers (e.g., Sidekiq, Delayed Job) should be run as separate services in your docker-compose.yml or as separate services within the App Platform. Ensure they are configured to connect to the same Redis or database instance as your main application.
# Example for docker-compose.yml with Sidekiq worker
version: '3.8'
services:
# ... db, redis, app services ...
worker:
build: . # Or reference your app image
command: bundle exec sidekiq
volumes:
- .:/app # Mount application code for development, remove for production
depends_on:
- db
- redis
environment:
RAILS_ENV: production
DATABASE_URL: postgresql://&user:&password@db:5432/&dbname
REDIS_URL: redis://redis:6379/0
# Add any other necessary environment variables for the worker
Monitoring and Logging in a Containerized Environment
Effective monitoring and logging are critical for maintaining the health of your containerized legacy applications. Standard tools like Prometheus and Grafana can be deployed to scrape metrics from your application and infrastructure. For logs, consider a centralized logging solution such as the ELK stack (Elasticsearch, Logstash, Kibana) or a cloud-native service like DigitalOcean’s Managed Log Drains.
Ensure your Ruby application logs to stdout and stderr. Docker will capture these streams, and your orchestration platform (Docker Compose or App Platform) can be configured to forward them to your chosen logging backend. For Rails applications, this often means configuring config/environments/production.rb to log to standard output.
# config/environments/production.rb
Rails.application.configure do
# ... other configurations ...
# Log to standard output
config.logger = ActiveSupport::Logger.new(STDOUT)
config.logger.level = config.log_level
# Log to standard error for critical errors
config.middleware.use(
ActiveSupport::Logger.new(STDERR),
level: :error
)
# ... other configurations ...
end
For metrics, integrate libraries like prometheus-client-mruby or similar for exposing application-level metrics. DigitalOcean Droplets can be monitored using their built-in monitoring tools, and you can install agents for more advanced metrics collection.