On 11 June 2025, we integrated AWS S3 (Simple Storage Service) with Rails Active Storage to handle property photographs. This wasn't about following cloud trends—it was about solving practical problems that every letting agency faces: photos that load quickly, storage that scales affordably, and images that remain accessible even if servers fail.
By the end of that evening, property photos were uploading directly to S3, generating multiple size variants automatically, and serving via CloudFront's global CDN. For letting agencies, this translates to listings that look professional, load quickly even on mobile networks, and don't consume expensive application server disk space.
What Your Team Will Notice
When your negotiator attaches property photos to a listing, the experience is seamless. They drag files into the upload area (or click to browse), see immediate progress indicators, and within seconds, the photos appear in the listing preview. The system handles everything else: resizing images for different contexts (thumbnail, gallery view, full-screen), generating optimised formats, and serving them via a global CDN.
Photos load remarkably quickly for prospective tenants, even during peak viewing times. That's because S3 integrates with CloudFront, AWS's content delivery network with edge locations across the UK, Europe, and globally. When someone views a property in Liverpool, the photos serve from a nearby data centre—not from your application server in London. The latency difference is measurable: typically 20–50ms instead of 100–200ms.
For agencies managing hundreds or thousands of properties, storage costs remain predictable. S3 charges roughly £0.02 per GB per month for storage and £0.07 per GB for data transfer. A typical property portfolio with 500 properties, each with 10 high-resolution photos (~2MB each), consumes about 10GB—costing around £0.20/month for storage plus modest transfer fees. Compare this to upgrading application servers to accommodate disk space, which typically jumps in £20–40 increments.
The system also generates multiple variants automatically. When you upload a 4000×3000 pixel photo from a modern camera, Active Storage creates:
- Thumbnail: 150×150 pixels for list views
- Medium: 800×600 pixels for detail pages
- Large: 1600×1200 pixels for lightbox galleries
This means mobile users on limited data plans aren't forced to download 5MB images just to browse listings. They get appropriately sized images that load instantly, while desktop users viewing galleries get full-resolution versions.
Under the Bonnet: How Active Storage Works
Rails' Active Storage, introduced in Rails 5.2 and matured significantly by Rails 8, abstracts file uploads behind a consistent API. Whether files store locally (for development), on S3 (production), or Google Cloud Storage (an alternative), the application code remains identical.
The initial configuration added the aws-sdk-s3 gem:
# Gemfile
gem "aws-sdk-s3", require: false
The require: false is intentional—the gem loads only when needed, reducing application boot time. Most requests don't involve file uploads, so there's no reason to load AWS SDK code on every request.
The storage configuration lives in config/storage.yml:
# config/storage.yml
amazon:
service: S3
access_key_id: <%= ENV['AWS_ACCESS_KEY_ID'] %>
secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %>
region: eu-west-2 # London region
bucket: <%= ENV['AWS_BUCKET_NAME'] %>
Credentials come from environment variables, never hardcoded. This means different environments (development, staging, production) can use different S3 buckets without code changes. It also means credentials can't accidentally leak into version control.
The production environment configuration points to S3:
# config/environments/production.rb
config.active_storage.service = :amazon
While development uses local disk storage:
# config/environments/development.rb
config.active_storage.service = :local
This dual configuration is crucial for developer productivity. Engineers can test photo uploads without requiring AWS credentials, while production benefits from S3's reliability and performance.
Database Schema: Active Storage's Design
Active Storage introduced three tables via migration:
class CreateActiveStorageTables < ActiveRecord::Migration[8.0]
def change
create_table :active_storage_blobs do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.bigint :byte_size, null: false
t.string :checksum
t.timestamps
end
add_index :active_storage_blobs, :key, unique: true
create_table :active_storage_attachments do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true
t.references :blob, null: false
t.timestamps
end
add_index :active_storage_attachments,
[:record_type, :record_id, :name, :blob_id],
name: "index_active_storage_attachments_uniqueness",
unique: true
create_table :active_storage_variant_records do |t|
t.references :blob, null: false
t.string :variation_digest, null: false
t.timestamps
end
# ...
end
end
Here's what each table does:
active_storage_blobs represents the actual files. Each blob has:
key: The S3 object key (path within the bucket)filename: Original filename uploaded by the usercontent_type: MIME type (e.g.,image/jpeg)byte_size: File size in byteschecksum: MD5 hash for integrity verification
active_storage_attachments links files to records. The polymorphic design means any model (Property, Tenancy, MaintenanceRequest, etc.) can have attachments without schema changes. For property photos:
record_type: "Property"record_id: The property's IDname: "photos" (the attachment name)blob_id: References the blob
active_storage_variant_records caches generated variants. When you request a thumbnail, Active Storage checks if it's already generated. If so, it serves the cached version instantly. If not, it generates it once, stores it, and serves cached versions thereafter.
PropertyPhoto Model: Ordering and Metadata
While Active Storage handles file storage, we created a dedicated PropertyPhoto model to manage ordering and metadata:
# app/models/property_photo.rb
class PropertyPhoto < ApplicationRecord
belongs_to :property
has_one_attached :image
validates :image, presence: true
validates :position, presence: true, numericality: { only_integer: true }
default_scope { order(:position) }
end
# Migration: 20250612081015_create_property_photos.rb
class CreatePropertyPhotos < ActiveRecord::Migration[8.0]
def change
create_table :property_photos do |t|
t.references :property, null: false, foreign_key: true
t.integer :position, null: false, default: 0
t.string :caption
t.timestamps
end
add_index :property_photos, [:property_id, :position]
end
end
This design separates concerns elegantly:
- Active Storage handles file storage, variant generation, and CDN delivery
- PropertyPhoto handles business logic: ordering (position), captions, and association with properties
The position field enables drag-and-drop reordering (implemented in Week 35). Agencies can control which photo appears first in listings—typically the most attractive exterior or living room shot that draws tenant interest.
The index on [:property_id, :position] ensures queries like "get all photos for property #142 in order" execute efficiently even with thousands of properties.
Generating Variants Efficiently
Rails' image processing uses either MiniMagick (wrapper for ImageMagick) or Vips (faster alternative). Our configuration uses MiniMagick for compatibility:
# Property model
class Property < ApplicationRecord
has_many :property_photos, -> { order(:position) }, dependent: :destroy
accepts_nested_attributes_for :property_photos, allow_destroy: true
# Accessing a variant in views:
# property_photo.image.variant(resize_to_limit: [800, 600])
end
In views, generating variants is straightforward:
<%# app/views/properties/_property.html.erb %>
<% property.property_photos.each do |photo| %>
<%= image_tag photo.image.variant(resize_to_limit: [400, 300]),
alt: photo.caption || "Property photo",
loading: "lazy",
class: "rounded-lg shadow" %>
<% end %>
The variant method is intelligent:
- It checks if this variant exists (via
active_storage_variant_records) - If not, it generates the variant, uploads to S3, and records it
- Returns a URL pointing directly to S3/CloudFront
Subsequent requests for the same variant skip generation entirely—it just returns the S3 URL. This makes variants essentially "free" after the first generation.
Security Considerations
S3 buckets can be configured as public (anyone can read) or private (authentication required). For property listings, photos are typically public—you want prospective tenants to view them without logging in. However, documents like tenancy agreements or compliance certificates should be private.
Our S3 bucket policy (configured via AWS console, not in code) allows public read access for photos:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::BUCKET_NAME/*"
}
]
}
Note: The actual bucket name includes random elements to prevent enumeration attacks and is not disclosed publicly.
For private files (implemented in later weeks), Active Storage generates signed URLs with expiry times:
# Generates a URL valid for 15 minutes
document.url(expires_in: 15.minutes)
These URLs include cryptographic signatures that AWS validates. Without the signature, the file remains inaccessible. This is critical for GDPR compliance—tenant documents must not be publicly accessible.
Performance and Cost Implications
Let's run numbers for a typical letting agency:
Storage costs:
- 300 properties × 8 photos × 2MB = ~4.8GB
- S3 Standard: £0.023/GB/month = £0.11/month
- Negligible for most agencies
Transfer costs:
- 10,000 property views/month × 8 photos × 100KB (medium variant) = ~8GB
- Data transfer: £0.07/GB = £0.56/month
- CloudFront reduces this further by caching at edge locations
Total: ~£0.70/month for photo hosting. Compare this to purchasing additional server storage (typically £20+ per 100GB) or upgrading Heroku dyno disk space.
Backup and Redundancy
S3 provides 99.999999999% (eleven nines) durability by storing data redundantly across multiple facilities. In practical terms, if you store 10,000 photos, you can expect to lose one file every 10 million years due to S3 failure.
For agencies, this means:
- No need for separate backup infrastructure for photos
- No risk of losing photos if a server fails
- Accidental deletions are recoverable via S3 versioning (if enabled)
We didn't enable versioning initially to minimise costs, but it's a single configuration change if required:
aws s3api put-bucket-versioning \
--bucket let-admin-production \
--versioning-configuration Status=Enabled
What About Alternative Storage Solutions?
Valid alternatives exist:
Cloudinary: Excellent for image transformations and optimisation, but more expensive (~£50/month for comparable usage). Best for agencies prioritising advanced image processing (face detection, automatic cropping, watermarking).
Google Cloud Storage: Comparable to S3 in pricing and reliability. We chose S3 because Heroku (which runs on AWS) has better integration, reducing latency.
Self-hosted (NAS/SAN): Feasible for single-office agencies but introduces hardware maintenance, backup responsibilities, and scaling challenges. Not recommended unless you have existing infrastructure and technical staff.
What's Next
With photo storage solved, the following day (12 June) we integrated photo uploads into the Property model. This meant negotiators could attach photos directly when creating or editing property listings—a seamless workflow that would mature further in Week 35 with drag-and-drop reordering and progressive uploads.
The infrastructure decisions made this week—using S3 for reliability, Active Storage for flexibility, and CDN for performance—would support increasingly sophisticated photo features over the coming months. But the foundation was solid from day one: photos uploaded quickly, stored securely, and served fast.
Related articles:
