Delivering optimized images quickly is crucial for web performance. While commercial CDNs offer robust solutions, developers can set up a free image CDN using GitHub Pages and JavaScript-based CDN libraries.

Understanding image CDNs

An Image CDN (Content Delivery Network) caches and serves images from servers geographically closer to users, significantly improving load times. Optimizing images through a CDN reduces bandwidth, enhances SEO, and boosts overall web performance.

Setting up GitHub pages

GitHub Pages provides free static hosting directly from your GitHub repositories:

  1. Create a GitHub repository named image-cdn.

  2. Clone the repository locally:

    git clone https://github.com/your-username/image-cdn.git
    cd image-cdn
    
  3. Create an images folder and add your images.

  4. Commit and push your changes:

    git add images
    git commit -m "Add initial images"
    git push origin main
    
  5. Enable GitHub Pages in your repository settings, selecting the main branch as the source.

Your images are now accessible via https://your-username.github.io/image-cdn/images/your-image.jpg.

GitHub pages limitations

Be aware of GitHub Pages' limitations:

  • Soft bandwidth limit of 100GB per month.
  • Recommended repository size under 1GB (strongly recommended under 5GB).
  • No built-in image processing.
  • The default GitHub Pages domain may be blocked in some corporate environments.

These limitations make GitHub Pages suitable for small to medium projects.

Integrating a JavaScript CDN

To optimize image delivery, integrate a JavaScript CDN library like jsDelivr. jsDelivr provides global caching.

Reference your images through jsDelivr:

<img
  src="https://cdn.jsdelivr.net/gh/your-username/image-cdn/images/your-image.jpg"
  alt="Optimized Image"
/>

This ensures your images are cached globally.

Reliability and fallback

While jsDelivr offers excellent performance, implement fallback strategies:

function loadImage(imageElement, primarySrc, fallbackSrc) {
  imageElement.onerror = function () {
    console.warn('Primary CDN failed, using fallback')
    imageElement.src = fallbackSrc
  }
  imageElement.src = primarySrc
}

const img = document.getElementById('my-image')
loadImage(
  img,
  'https://cdn.jsdelivr.net/gh/your-username/image-cdn/images/image.jpg',
  'https://your-username.github.io/image-cdn/images/image.jpg',
)

Alternative CDN options include Statically, unpkg, or Cloudflare Pages.

Automating with GitHub actions

Automate image optimization and deployment using GitHub Actions:

Create .github/workflows/compress-images.yml:

name: Compress Images
on:
  pull_request:
    paths:
      - '**.jpg'
      - '**.jpeg'
      - '**.png'
      - '**.webp'
jobs:
  build:
    if: github.event.pull_request.head.repo.full_name == github.repository
    name: calibreapp/image-actions
    permissions:
      contents: write
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@v4
      - name: Compress Images
        uses: calibreapp/image-actions@main
        with:
          githubToken: $

This workflow automatically optimizes images on pull requests.

Implementing custom optimization

For more control, use a Node.js script with Sharp:

const sharp = require('sharp')
const fs = require('fs').promises
const path = require('path')

async function optimizeImage(inputPath, outputPath, options = {}) {
  try {
    const image = sharp(inputPath)
    const metadata = await image.metadata()

    const config = {
      jpeg: { quality: 80, progressive: true },
      png: { quality: 80, compressionLevel: 9 },
      webp: { quality: 80 },
    }

    switch (metadata.format) {
      case 'jpeg':
        await image.jpeg(config.jpeg).toFile(outputPath)
        break
      case 'png':
        await image.png(config.png).toFile(outputPath)
        break
      case 'webp':
        await image.webp(config.webp).toFile(outputPath)
        break
      default:
        throw new Error(`Unsupported format: ${metadata.format}`)
    }

    console.log(`Optimized: ${path.basename(inputPath)}`)
    return outputPath
  } catch (error) {
    console.error(`Failed to optimize ${inputPath}:`, error)
    throw error
  }
}

async function processDirectory(inputDir, outputDir) {
  try {
    await fs.mkdir(outputDir, { recursive: true })
    const files = await fs.readdir(inputDir)

    for (const file of files) {
      const inputPath = path.join(inputDir, file)
      const outputPath = path.join(outputDir, file)
      const stat = await fs.stat(inputPath)

      if (stat.isFile() && /\.(jpe?g|png|webp)$/i.test(file)) {
        await optimizeImage(inputPath, outputPath)
      }
    }

    console.log('All images processed successfully')
  } catch (error) {
    console.error('Directory processing failed:', error)
  }
}

processDirectory('original-images', 'optimized-images')

Testing your setup

Verify your CDN:

  1. Browser Developer Tools: Check the Network panel.

  2. Performance Testing Tools:

  3. Real User Monitoring: Use the Performance API:

    document.addEventListener('DOMContentLoaded', () => {
      window.addEventListener('load', () => {
        const imageEntries = performance
          .getEntriesByType('resource')
          .filter((entry) => entry.initiatorType === 'img')
    
        const totalLoadTime = imageEntries.reduce((sum, entry) => sum + entry.duration, 0)
        const avgLoadTime = totalLoadTime / imageEntries.length
        console.log(`Average image load time: ${avgLoadTime.toFixed(2)}ms`)
    
        imageEntries.forEach((entry) => {
          console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`)
        })
      })
    })
    

Security

Implement these security measures:

Cors headers

Create a _headers file:

/*
  Access-Control-Allow-Origin: *
  Cache-Control: public, max-age=31536000
  Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.jsdelivr.net

Prevent hotlinking

If traffic exceeds limits, use a JavaScript check:

document.addEventListener('DOMContentLoaded', () => {
  const images = document.querySelectorAll('img[data-src]')
  const allowedDomains = ['yourdomain.com', 'localhost']

  if (allowedDomains.some((domain) => window.location.hostname.includes(domain))) {
    images.forEach((img) => {
      img.src = img.getAttribute('data-src')
    })
  }
})

Advanced optimization

Responsive images

<img
  srcset="
    https://cdn.jsdelivr.net/gh/your-username/image-cdn/images/image-small.jpg   480w,
    https://cdn.jsdelivr.net/gh/your-username/image-cdn/images/image-medium.jpg  800w,
    https://cdn.jsdelivr.net/gh/your-username/image-cdn/images/image-large.jpg  1200w
  "
  sizes="(max-width: 600px) 480px,
         (max-width: 1200px) 800px,
         1200px"
  src="https://cdn.jsdelivr.net/gh/your-username/image-cdn/images/image-large.jpg"
  alt="Responsive Image"
  loading="lazy"
/>

Modern image formats

Use a format detection and fallback system:

<picture>
  <source
    srcset="https://cdn.jsdelivr.net/gh/your-username/image-cdn/images/image.avif"
    type="image/avif"
  />
  <source
    srcset="https://cdn.jsdelivr.net/gh/your-username/image-cdn/images/image.webp"
    type="image/webp"
  />
  <img
    src="https://cdn.jsdelivr.net/gh/your-username/image-cdn/images/image.jpg"
    alt="Optimized image with format fallbacks"
    loading="lazy"
  />
</picture>

Benefits and limitations

Benefits:

  • Zero cost for small to medium projects.
  • Easy setup and automation.
  • Improved global performance.

Limitations:

  • GitHub Pages has bandwidth (100GB/month) and storage limits (1-5GB recommended).
  • Less control compared to commercial CDNs.
  • No built-in image transformation.

For larger projects or advanced needs, consider services like Transloadit's Image Manipulation Service.

By following these steps, you've created a free image CDN using GitHub Pages, a JavaScript CDN, and GitHub Actions.