Building MapView: Architecture, CI/CD, and Production Deployment on Railway

Building MapView: Architecture, CI/CD, and Production Deployment on Railway
Building MapView: Architecture, CI/CD, and Production Deployment on Railway

April 13, 2026

Introduction

MapView is a server-side map rendering engine for Rails that generates high-quality geographic visualizations without external API dependencies. This article explores the technical architecture behind the project, its deployment pipeline, and how we leverage Railway for production hosting.

The project encompasses multiple layers: the core rendering engines (ruby-libgd, libgd-gis), the Rails gem integration (MapView), and the live demo application. Understanding how these pieces fit together reveals the challenges and solutions of building production-grade infrastructure for a data-intensive application.

Project Architecture

Article content

Three-Layer Stack

MapView is built as three distinct, composable layers:

Layer 1: ruby-libgd A high-performance Ruby wrapper around libgd, the C graphics library. This provides direct access to bitmap rendering, image manipulation, and low-level graphics operations. By wrapping libgd in native C extensions with FFI bindings, we maintain Ruby’s expressiveness while preserving C-level performance.

Layer 2: libgd-gis The GIS-specific raster engine built on top of ruby-libgd. This layer handles geographic coordinate systems, projection transformations, tile fetching from basemap providers (OpenStreetMap, Carto, Mapnik, etc.), and raster composition. It’s the abstraction that translates geographic data into rendered images.

Layer 3: MapView (Rails Gem) The Rails-specific integration layer. This is what developers use in their applications. It provides an ActiveJob-compatible rendering interface, webhook support, caching strategies, and seamless integration with Rails patterns.

Separation of Concerns

This three-layer design allows each component to have a single responsibility:

  • ruby-libgd: Graphics rendering
  • libgd-gis: GIS logic
  • MapView: Rails integration

Each layer can be versioned, tested, and deployed independently. A breaking change in the GIS engine doesn’t require updating the Rails gem until it’s been properly integrated and tested.

The Demo Application

The live demo at https://map-view-demo.up.railway.app is a full Rails 8 application that showcases MapView’s capabilities. It includes:

  • An interactive map viewer with 90+ countries and regions
  • GeoJSON upload and rendering
  • Multiple basemap styles
  • Zoom level controls
  • Pricing page with subscription management
  • Contact form for enterprise inquiries
  • Blog (RubyStackNews) integration

The demo doubles as a proof-of-concept for potential customers: “This is what your application could look like.”

Deployment Architecture on Railway

Project Structure

ror_map_view/
├── Dockerfile # Production image definition
├── Gemfile # Rails dependencies
├── config/
│ ├── database.yml # PostgreSQL configuration
│ ├── routes.rb # Route definitions
│ └── credentials.yml.enc # Encrypted secrets
├── app/
│ ├── controllers/ # Rails controllers
│ ├── views/ # HTML/ERB templates
│ ├── models/ # ActiveRecord models
│ └── mailers/ # ActionMailer definitions
├── map_view/ # Gem in monorepo
│ ├── lib/
│ ├── gemspec
│ └── README.md
└── public/ # Static assets

Services on Railway

Primary Service: Rails Application

  • Buildpack: Dockerfile
  • Health check: / returns HTTP 200
  • Restart policy: Automatic on crash
  • Environment variables: RAILS_ENV=production RAILS_LOG_TO_STDOUT=true RAILS_SERVE_STATIC_FILES=true SECRET_KEY_BASE (auto-generated) DATABASE_URL (auto-provided by PostgreSQL service)

Secondary Service: PostgreSQL

  • Version: Latest stable
  • Automatic backups: Daily
  • Connection pooling: Handled by Rails’ built-in pool
  • Storage: Persistent volume (survives app restarts)

Networking

Railway automatically provides:

  • Private network between Rails app and PostgreSQL
  • Environment variable DATABASE_URL injected at runtime
  • SSL connections for external HTTPS traffic
  • CDN for static assets

No manual network configuration needed—Railway handles service discovery.

CI/CD Pipeline

Local Development → GitHub

Developers work locally with docker-compose:

yaml

services:
postgres:
image: postgis/postgis:16-3.4
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: delivery_development
web:
build: .
command: bundle exec rails server -b 0.0.0.0
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/delivery_development

This mirrors production exactly: same Ruby version, same gems, same database (PostGIS-enabled PostgreSQL).

Deployment Flow

1. Developer pushes to GitHub main branch
2. Railway webhook triggered automatically
3. Railway clones repository
4. Docker image built (layer caching optimizes rebuild time)
5. Secrets injected (DATABASE_URL, SECRET_KEY_BASE, etc.)
6. New container spun up, old one kept warm during transition
7. Health check passes → traffic switched to new container
8. Old container shut down after grace period

This zero-downtime deployment strategy means users never see 502 errors during a release.

Dockerfile Optimization

Our Dockerfile uses multi-stage building to keep the final image lean:

dockerfile

FROM ruby:3.3.0
RUN apt-get update && apt-get install -y build-essential libgd-dev pkg-config postgresql-client
RUN gem install rails
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test
COPY . .
RUN bundle exec rails assets:precompile
EXPOSE 3000
CMD bundle exec rails server -b 0.0.0.0 -p 3000 -e production

Key optimizations:

  • Only production gems installed (no development tools in final image)
  • Assets precompiled during build (not at runtime)
  • Minimal system dependencies
  • No intermediate layers left behind

Handling Secrets and Configuration

Encrypted Credentials

Rails 8’s encrypted credentials system keeps secrets safe in version control:

bash

bundle exec rails credentials:edit

This creates config/credentials.yml.enc (encrypted) and config/master.key (the decryption key).

In development, master.key lives on the filesystem. In production, Railway injects it as an environment variable RAILS_MASTER_KEY, and Rails decrypts credentials.yml.enc at boot time.

Environment Variables

Non-secret configuration uses environment variables:

ruby

# config/environments/production.rb
config.database_url = ENV['DATABASE_URL'] # Provided by Railway

This follows the 12-factor app methodology: code never changes between environments, only configuration.

Database Migrations

Migrations run automatically on Railway during deploy:

dockerfile

# In entrypoint script or Procfile
bin/rails db:migrate

This ensures schema is always in sync with the running code. If a migration fails, the entire deployment is rolled back, preventing a broken state.

Monitoring and Debugging

Logs

All logs stream to STDOUT (configured with RAILS_LOG_TO_STDOUT=true). Railway captures these automatically:

GET /contact 200 OK (45ms)
Rendered layout/application.html.erb (2.5ms)
Started POST /contacts 400 Bad Request (1ms)

Developers access logs from Railway’s dashboard or CLI:

bash

railway logs

Error Tracking

The demo includes error reporting (optional):

  • Sentry for exception tracking
  • New Relic for performance monitoring

These could be added by setting environment variables in Railway’s project settings.

Health Checks

Railway monitors container health:

  • Responds to HTTP requests on port 3000
  • Exits gracefully on SIGTERM (gives running requests time to finish)
  • Restarts if unhealthy for >30 seconds

Performance Considerations

Render Time

Map rendering happens synchronously in the controller, but Rails queues are configured for background jobs:

ruby

class MapRenderJob < ApplicationJob
queue_as :default
def perform(params)
MapView.render(params)
end
end

For heavy workloads, jobs can be offloaded to a background worker (Sidekiq) running in a separate Railway service.

Caching

The demo uses Redis (optional) for fragment caching:

ruby

<% cache "map_#{@region}", expires_in: 1.hour do %>
<%= render_map(@region) %>
<% end %>

This avoids re-rendering the same map multiple times within an hour.

Database Queries

PostGIS indices optimize geographic queries:

sql

CREATE INDEX idx_locations_geom ON locations USING gist(geom);

This allows fast spatial joins for features like “find all map tiles within bounding box.”

Disaster Recovery

Backups

Railway provides automatic PostgreSQL backups. Restore options:

  1. Point-in-time recovery: Restore to any moment in the past 7 days
  2. Manual backups: Export database as SQL dump
  3. Replication: Set up read replicas for high-availability scenarios

Rollback Strategy

If a deploy goes wrong:

bash

railway deploy --from <previous-deployment-id>

Railway keeps the last N successful deployments available for instant rollback. This is faster than waiting for a new build and vastly simpler than manual database rollbacks.

Cost Optimization

Resource Sizing

The demo runs on Railway’s starter tier:

  • 1 vCPU for Rails app
  • 512MB RAM
  • Shared PostgreSQL (scalable to dedicated)
  • ~$20-30/month for hobby/demo load

For production, size resources to actual demand using Railway’s metrics dashboard.

Cold Start Optimization

Ruby apps have a known cold-start penalty. Mitigate with:

  • Keep containers warm (avoid 30+ minute idle periods)
  • Use preload_app! in Puma configuration
  • Consider Ruby 3.3’s YJIT compiler for faster boot times

Asset Delivery

Static assets (CSS, JS, images) are served from Rails with RAILS_SERVE_STATIC_FILES=true. For higher traffic, add a CDN (Cloudflare) in front of Railway.

Lessons Learned

What Worked Well

  1. Docker from day one: Eliminates “works on my machine” problems
  2. PostgreSQL with PostGIS: Eliminates external tile server dependency
  3. Railway’s simplicity: No Kubernetes to learn, no complex networking
  4. Layered architecture: Each gem is independently testable
  5. Encrypted credentials in Git: No separate secrets management system needed

Challenges Overcome

  1. Master key synchronization: Initially forgot that Railway needed RAILS_MASTER_KEY injected—solved by reading Rails 8 docs carefully
  2. Database migrations timing: First deploy tried to migrate before secrets were ready—fixed by ensuring migrations run in the Dockerfile, not on boot
  3. Static asset serving: Forgot RAILS_SERVE_STATIC_FILES=true—Railway was serving 404s for CSS/JS until this was added
  4. Memory usage: Ruby processes are memory-hungry; watched metrics closely to size containers appropriately

Conclusion

MapView’s production setup is intentionally simple: a single Rails app, a managed database, and automated deployments. This simplicity is a feature, not a limitation. By using Railway’s managed services, we avoid the operational complexity of managing Kubernetes, load balancers, and database replication.

The three-layer architecture (ruby-libgd → libgd-gis → MapView gem) allows us to ship faster and test more thoroughly. Each layer has a single responsibility, making debugging and extending the system straightforward.

The demo application proves the concept works. Potential customers can click around, upload GeoJSON, and see exactly what they’d get. From there, it’s a conversation about how to integrate MapView into their specific workflows.

Getting Started

To deploy MapView yourself on Railway:

  1. Clone the repository: git clone https://github.com/ggerman/ror_map_view
  2. Create a new Railway project and connect your GitHub account
  3. Deploy the main branch—Railway handles the rest
  4. Access the demo at the generated URL

For production deployments, reach out at https://map-view-demo.up.railway.app/contact.


About the Author: Germán Alberto Giménez Silva is a Senior Rails engineer with 15+ years of experience building production systems. He’s the creator of ruby-libgd, libgd-gis, and MapView. When not writing Ruby, he’s exploring how geographic data and Rails can solve real-world problems.

Published on RubyStackNews

Article content

Leave a comment