Lessons from SVG Icon Filtering
The Problem: Icon Library Bloat
Our Rails application uses several SVG icon libraries (Heroicons, Phosphor Icons, etc.) for a beautiful UI. The complete set includes over 10,000 SVG icons, but I only use around 25! By default, all these icons were included in my Docker image, unnecessarily bloating it.
Initial symptoms:
- Slow Docker builds
- Large final image size (extra hundreds of MBs)
- Longer deployment times
- Wasted storage and bandwidth
Solution 1: Smart SVG Icon Filtering
I created a Rake task that:
- Scans my application code to find which icons are used
- Keeps only those icons and removes everything else
- Intelligently handles icon libraries and variants
Here's my implementation in assets.rake:
lang-ruby
namespace :assets do
desc <<~DESC
Filter SVG icons to only include those used in the application
DESC
task filter_svg_icons: :environment do
require "fileutils"
# Set paths
app_dir = Rails.root.join("app")
icon_dir = Rails.root.join("app/assets/svg/icons")
backup_dir = Rails.root.join("tmp/all_icons_backup")
# Ensure backup directory exists
FileUtils.mkdir_p(backup_dir)
# Function to find all icon usages in Ruby files
def find_used_icons(app_dir)
icons = Set.new
libraries = Set.new
variants = Set.new
# Define patterns to match
icon_patterns = [
/icon\(\s*["']([^"']+)["']/,
/icon\(\s*:([^,\s\)]+)/,
]
library_pattern = /library:\s*["']?([^"',\s\)]+)["']?/
variant_pattern = /variant:\s*["']?([^"',\s\)]+)["']?/
# Default library from configuration
libraries << RailsIcons.configuration.default_library
# Walk through all Ruby files
Dir.glob("#{app_dir}/**/*.rb").each do |file|
content = File.read(file)
# Find all icons
icon_patterns.each do |pattern|
content.scan(pattern).flatten.each do |icon_name|
# Normalize icon name (convert snake_case or camelCase to dash-case)
normalized_name = icon_name.gsub(/([a-z])([A-Z])/, '\1-\2').downcase.tr("_", "-")
icons << normalized_name
end
end
# Find all libraries
content.scan(library_pattern).flatten.each do |library|
libraries << library.downcase
end
# Find all variants
content.scan(variant_pattern).flatten.each do |variant|
variants << variant.downcase
end
end
# Only add variants from libraries that are actually used
actually_used_libraries = libraries.to_a
RailsIcons.configuration.libraries&.each do |library_name, library|
# Only process libraries that are actually used in the application
if actually_used_libraries.include?(library_name.to_s)
library.variants&.each_key do |variant_name|
variants << variant_name.to_s
end
end
end
[icons.to_a, libraries.to_a, variants.to_a]
end
icons, libraries, variants = find_used_icons(app_dir)
puts "Found #{icons.size} unique icons used in the app"
puts "Found libraries: #{libraries.join(', ')}"
puts "Found variants: #{variants.join(', ')}"
# Backup all icons first (only if not already backed up)
if !Dir.exist?(backup_dir) || Dir.empty?(backup_dir)
puts "Backing up all original icons to #{backup_dir}"
FileUtils.cp_r(Dir.glob("#{icon_dir}/*"), backup_dir)
end
# Identify files to keep
files_to_keep = []
libraries.each do |library|
variants.each do |variant|
next unless File.directory?("#{icon_dir}/#{library}/#{variant}")
icons.each do |icon|
source_file = "#{icon_dir}/#{library}/#{variant}/#{icon}.svg"
files_to_keep << source_file if File.exist?(source_file)
end
end
end
# Clean up unused SVG files
total_removed = 0
# First clean up individual SVG files in used library/variant directories
libraries.each do |library|
variants.each do |variant|
library_variant_path = "#{icon_dir}/#{library}/#{variant}"
next unless File.directory?(library_variant_path)
Dir.glob("#{library_variant_path}/*.svg").each do |file|
next if files_to_keep.include?(file)
FileUtils.rm(file)
puts "Removed unused icon: #{file.gsub("#{icon_dir}/", '')}"
total_removed += 1
end
end
end
# Then remove entire libraries that aren't used
Dir.glob("#{icon_dir}/*").each do |library_path|
library_name = File.basename(library_path)
next if libraries.include?(library_name)
icon_count = Dir.glob("#{library_path}/**/*.svg").count
if icon_count > 0
puts "Removing unused library: #{library_name} (#{icon_count} icons)"
FileUtils.rm_rf(library_path)
total_removed += icon_count
end
end
puts "Removed #{total_removed} unused SVG icons"
puts "Kept #{files_to_keep.size} SVG icons that are used in the application"
end
# Only hook into precompile task during Docker build
task precompile: :filter_svg_icons if ENV["RAILS_ENV"] == "production" && ENV["DOCKER_BUILD"] == "true"
endI also created a restore task for development convenience:
lang-ruby
namespace :assets do
desc "Restore all original SVG icons from backup"
task restore_svg_icons: :environment do
require "fileutils"
icon_dir = Rails.root.join("app/assets/svg/icons")
backup_dir = Rails.root.join("tmp/all_icons_backup")
if Dir.exist?(backup_dir) && !Dir.empty?(backup_dir)
puts "Restoring original icons from backup..."
# First clean existing directories to avoid leftover files
libraries = Dir.glob("#{backup_dir}/*").map { |f| File.basename(f) }
libraries.each do |library|
library_path = "#{icon_dir}/#{library}"
if File.directory?(library_path)
FileUtils.rm_rf(library_path)
FileUtils.mkdir_p(library_path)
end
end
# Restore from backup
FileUtils.cp_r(Dir.glob("#{backup_dir}/*"), icon_dir)
restored_count = Dir.glob("#{icon_dir}/**/*.svg").count
puts "Successfully restored #{restored_count} SVG icons!"
else
puts "No backup found in #{backup_dir}. Nothing to restore."
end
end
endThen I hooked this into my Dockerfile:
# First, filter SVG icons to only include those used in the application RUN DOCKER_BUILD=true DOMAIN=cosmos.io SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:filter_svg_icons # Then compile assets for production without requiring secret RAILS_MASTER_KEY RUN DOCKER_BUILD=true DOMAIN=cosmos.io SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
The Results
This approach reduced my SVG icon count from 10,360 to 25, removing all unused icon libraries (including Phosphor with 9,072 icons). My Docker image size decreased significantly, and build times improved.
Solution 2: Optimizing .dockerignore
An often-overlooked tool in Docker optimization is the .dockerignore file. Like .gitignore, it tells Docker which files and directories to exclude from the build context. This simple step saved 7 GB on my particular docker image 🤦♂️
Why is .dockerignore important?
- Reduces build context size: Only relevant files are sent to the Docker daemon
- Improves build speed: Less data to process means faster builds
- Prevents unnecessary cache invalidation: Changes to ignored files don't trigger rebuilds
- Enhances security: Prevents sensitive files from being included in images
my optimized .dockerignore
# Version Control .git/ .github/ .gitignore # Development/IDE files .idea/ .DS_Store .env* .kamal/ !.env.example .ruby-lsp/ .solargraph.yml .rspec # Logs and temp files log/* tmp/* !/log/.keep !/tmp/.keep !/tmp/pids/ !/tmp/pids/.keep # Test files and coverage reports spec/ coverage/ .knapsack_pro/ .simplecov # Node dependencies node_modules/ yarn-error.log # Compiled assets (handled by the Dockerfile) /public/assets /public/vite-test/ /app/assets/builds/* !/app/assets/builds/.keep # Other large directories not needed in production storage/* !/storage/.keep
Advanced Docker Optimization: Cache Busting for Asset Tasks
When using multi-stage builds, Docker caching can sometimes prevent your filtering tasks from running if there are no detected changes. To ensure filtering runs consistently, we added a cache-busting timestamp:
# Add a build timestamp to invalidate cache for asset compilation steps
ARG BUILD_TIMESTAMP
ENV BUILD_TIMESTAMP=${BUILD_TIMESTAMP:-$(date +%s)}
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/
# Run filtering task
RUN DOCKER_BUILD=true DOMAIN=cosmos.io SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:filter_svg_iconsThen build with:
docker build --build-arg BUILD_TIMESTAMP=$(date +%s) -t myapp:latest .
Lessons Learned
- Be mindful of assets: Icon libraries, fonts, and other assets can silently bloat your application
- Invest in build-time optimization: Tasks like my SVG filter pay dividends with every build and deployment
- Regularly review your Docker image: Use docker history and tools like Dive to identify bloat
- Keep .dockerignore updated: As your project grows, regularly revisit what should be excluded
- Watch out for caching gotchas: Docker's caching is powerful but can sometimes work against you
Docker optimization is an ongoing process, not a one-time effort. Regularly revisiting your build process and being intentional about what goes into your images will help keep your deployments fast and resource-efficient.
