Slimming Down Your Docker Images

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:

  1. Scans my application code to find which icons are used
  2. Keeps only those icons and removes everything else
  3. 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?

  1. Reduces build context size: Only relevant files are sent to the Docker daemon
  2. Improves build speed: Less data to process means faster builds
  3. Prevents unnecessary cache invalidation: Changes to ignored files don't trigger rebuilds
  4. 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

  1. Be mindful of assets: Icon libraries, fonts, and other assets can silently bloat your application
  2. Invest in build-time optimization: Tasks like my SVG filter pay dividends with every build and deployment
  3. Regularly review your Docker image: Use docker history and tools like Dive to identify bloat
  4. Keep .dockerignore updated: As your project grows, regularly revisit what should be excluded
  5. 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.

Comments

No comments yet. Be the first to comment!
Your email address will be verified before your first comment is posted. It will not be displayed publicly.
Legal Information
By commenting, you agree to our Privacy Policy and Terms of Service.