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" end
I 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 end
Then 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_icons
Then 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.