In the digital age, the power of visuals cannot be overstated. Images form the cornerstone of digital content, from websites to mobile apps, making their optimization an essential aspect of the digital experience. However, many businesses and content creators find themselves grappling with the escalating costs of image optimization services, especially as their scale of operations expands. This is where the concept of creating your own image optimization and resizing service comes into play.

Imagine being able to offer an additional, highly valuable service to your clients without incurring significant expenses. Image optimization is not just a necessity; it's a potential value-add service that can distinguish your offerings from competitors. The best part? Implementing your own image optimization service is far simpler and more cost-effective than you might think.

This guide is designed to demystify the process of setting up your own image optimization and resizing service. Contrary to popular belief, you don't need a hefty budget or extensive technical expertise. With some basic knowledge and the right tools, you can establish a service that rivals those of expensive third-party providers, ensuring your images are always web-ready, visually appealing, and optimized for performance.

Whether you're a web developer, a digital agency, or a content creator looking to enhance your digital assets, this article will walk you through the steps of creating an efficient, practically free image optimization service. Let's dive in and unlock the potential of optimized visuals for your projects!


Overview

1. Using PHP for Image Resizing:

The foundation of your image resizing service will be a PHP script. PHP, known for its simplicity and efficiency, is perfect for handling image manipulation tasks. To get started, you need either a Virtual Private Server (VPS) or a shared hosting plan that supports the PHP-Imagick extension, which is essential for image processing. A great option to consider is Hostinger’s shared hosting, which comes equipped with the PHP-Imagick extension. This setup ensures you have the necessary environment to run your PHP scripts efficiently, handling image resizing tasks seamlessly. This guide will assume you're using Hostinger but the process should be the same on a VPS. If you want a great deal on a Hostinger plan, please use the link at the bottom to help support me!

2. Employing a CloudFlare Worker:

The final piece of the puzzle involves creating a CloudFlare worker. This worker is responsible for making image resize requests to the PHP server and then caching the resized images in CloudFlare’s global network. This step is crucial because it significantly reduces the load on your server, as CloudFlare's network handles the distribution of the optimized images. By caching the images, you ensure that subsequent requests are served faster, enhancing the overall user experience and reducing bandwidth costs.


1. Setting Up the PHP Endpoint

This guide will take you through the steps of setting up your PHP endpoint on Hostinger. We're going to use a fictional domain images-api.example.com. I decided to do a walkthrough on this because you'll need to navigate through the weeds of Hostinger's DIY templates and paid add-on services to just get a plain and simple shared hosting space attached to a subdomain.

From your Hostinger dashboard, click the Add or migrate a website button.

Create a new website

On the next screen, click Skip, I don't want personalized experience.

Click skip, I don't want a personalized experience

Next, click Skip, create an empty website. 🥵 We're almost there!

Click skip, create an empty website

Now click Use an existing domain and enter your chosen subdomain. I've used images-api.example.com.

Enter your subdomain

On the next screen, just click Continue. On the last screen, you can just ignore the other options here. Instead, just click the Hostinger logo to return to the hPanel. Then Pro Panel at the top right of the screen.

Click on Pro Panel

From the Pro Panel, click the Hosting tab and then expand the Advanced category from the left sidebar, then click SSH Access. You'll need to setup your DNS for your subdomain to point to the IP address listed here. I recommend you go ahead and setup SSH access so you can use rsync or sftp to upload your files. You can always use the file manager instead, but it's not the best option. If you have a public SSH key, you can upload it here or you can use password authentication (which is less secure).

Copy your IP and setup SSH

Ok, that should complete your setup for your shared hosting space. Go ahead and type your domain URL into a web browser. You should hopefully get a Hostinger placeholder page.


Building the Images API

Now it's time to write some PHP code! Here's the directory structure we'll use:

├── lib
│   ├── ApiHelper.class.php
├── public_html
│   ├── index.php
│   ├── 404.html
├── routes
│   ├── default.php
│   ├── error404.php
│   ├── resize.php
├── config.php
├── main.php
└── .env

The best way to develop in PHP is to setup a local development environment. There are a number of ways you accomplish this. You can use Docker or you could setup a Virtual Box Ubuntu server. This tutorial will not go into setting that up, so for sake of simplicity, we're going to work directly out of the Hostinger shared space.

Ok, now we're going to setup a shared access key that only our PHP server and the CloudFlare worker will know. Our clients will not be accessing the PHP server directly. Here's a quick way to generate a 32-byte secure ASCII string using OpenSSL:

openssl rand -base64 32

Optionally, you can use Node.js to do the same (especially if you're on Windows):

node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

Copy this and create your .env file and create an env var called ACCESS_KEY:

ACCESS_KEY="xt1LhW0OO0pmeVwW/grkV/zGaUsarWvA1OI7oCRcjTE="

Now let's create our world-facing PHP file. This will be the only file in our public_html directory. This will prevent anyone from ever being able to see our code. So if PHP ever stops working or something happens on Hostinger, your code will never be exposed.

<?php
require('../main.php');

Now let's create main.php, which will be our API router:

<?php
// Get our environment
define('DIR', realpath(getcwd() . '/../'));
// Load deps
require_once(DIR . '/vendor/autoload.php');
// Load config file
require_once(DIR . '/config.php');
// Split URI path and get our route
function getPath()
{
    $path = $_SERVER['REQUEST_URI'] ?? '';
    if (strpos($path, '?') !== false) {
        list($path, $_) = explode('?', $path);
    }
    $path = ltrim($path, '/');
    $path = preg_replace("/[^a-z\d\/_.-]+/", '', $path);
    $path = preg_replace("/\.+/", ".", $path);
    return $path;
}
$url = explode('/', getPath());
// Serve OPTIONS preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    if (isset($_SERVER['HTTP_ORIGIN']) && in_array($_SERVER['HTTP_ORIGIN'], ALLOWED_CORS_ORIGINS)) {
        header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
        header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type');
    }
    exit;
}
// Determine which route to use
if (!count($url) || empty($url[0])) {
    include DEFAULT_ROUTE_FILE;
} else {
    // Validate the route
    if (!in_array($url[0], ALLOWED_ROUTES)) {
        // Display a 404 page
        include ERROR_404_ROUTE_FILE;
        exit;
    }
    $filePath = ROUTE_DIR . '/' . $url[0] . '.php';
    // Validate the file path
    if (file_exists($filePath) && is_file($filePath) && is_readable($filePath)) {
        // Include the file
        include $filePath;
    } else {
        // Display a 404 page
        include ERROR_404_ROUTE_FILE;
    }
}

Next, let's create our config.php file:

<?php
// DIRS
define('ROUTE_DIR', DIR . '/routes');
define('PUB_DIR', DIR . '/public_html');
define('LIB_DIR', DIR . '/lib');
// Max image size of 12MB
define('MAX_IMAGE_SIZE', 12000000);
// Load env file
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->safeLoad();
$dotenv->required([
    'ACCESS_KEY',
]);
// Allowed CORS origins
define('ALLOWED_CORS_ORIGINS', []);
// Define a whitelist of allowed page names
define('ALLOWED_ROUTES', [
    'default',
    'resize',
]);
// route files
define('DEFAULT_ROUTE_FILE', ROUTE_DIR . '/default.php');
define('ERROR_404_ROUTE_FILE', ROUTE_DIR . '/error404.php');
define('ERROR_404_HTML_FILE', PUB_DIR . '/404.html');
// Autoload Classes
function autoload($className)
{
    include LIB_DIR . '/' . $className . '.class.php';
}
spl_autoload_register('autoload');

For the other files, you can check out this gist. NOTE: We're not using any CORS origins here because we don't need it for server-to-server communications. With this setup, though you can build your own PHP API for whatever purposes you need!

Next, let's create our resize.php route that will be the main brains of our operation:

<?php
$AUTH_TOKEN = $_ENV['ACCESS_KEY'];
$api = new ApiHelper();
// Assert allowed methods
$api->assertAllowedMethods(['GET']);
// Get X-Auth-Token header
$token = $_SERVER['HTTP_X_AUTH_TOKEN'] ?? '';
if (!$token) {
    $api->unauthorized('Missing X-Auth-Token header');
    exit;
}
if ($token !== $AUTH_TOKEN) {
    $api->unauthorized('Invalid X-Auth-Token header');
    exit;
}
$w = $_GET['w'] ?? ''; // width
$url = $_GET['url'] ?? ''; // image origin url
$f = $_GET['f'] ?? ''; // format
$q = $_GET['q'] ?? ''; // quality
if (!$w) {
    $api->badRequest('Missing width (w)');
    exit;
}
// Width must be integer
if (!is_numeric($w)) {
    $api->badRequest('Invalid width (w)');
    exit;
}
if (!$url) {
    $api->badRequest('Missing url');
    exit;
}
if (!$f) {
    $api->badRequest('Missing format (f)');
    exit;
}
// Format must be webp or avif
if (!in_array($f, ['webp', 'avif'])) {
    $api->badRequest('Invalid format (f)');
    exit;
}
// Create Imagick object and check if it is valid
$image = new Imagick($url);
if (!$image) {
    $api->badRequest('Invalid image url');
    exit;
}
// Preserve transparency
if ($image->getImageAlphaChannel()) {
    $image->setImageAlphaChannel(Imagick::ALPHACHANNEL_ACTIVATE);
}
// Get the current image dimensions
$imageWidth = $image->getImageWidth();
$imageHeight = $image->getImageHeight();
// Calculate the new height
$height = $w * ($imageHeight / $imageWidth);
// Set the image format
$image->setImageFormat($f);
// Set image quality
$image->setImageCompressionQuality($q);
// Do not resize if the image is already smaller than the requested width
if ($imageWidth < $w) {
    header('Content-Type: image/' . $f);
    echo $image->getImageBlob();
    exit;
}
// Resize the image
$image->resizeImage($w, $height, Imagick::FILTER_LANCZOS, 1);
// Output the image
header('Content-Type: image/' . $f);
echo $image->getImageBlob();
exit;

Ok, that should do it. You can use rsync to upload your files to your shared hosting directory, or you can use SFTP or the hPanel File Manager. NOTE: There's a file outside your public_html directory that says "do not upload here". You can just delete that file. You definitely want your code outside the public directory.

Here's a quick script you can create to rsync your files if you've setup a local dev server (which I recommend):

#!/bin/bash

rsync -avr --delete -e 'ssh -p 65002' /path/to/your/local/dir/ [email protected]:/home/hostinger_username/domains/images-api.example.com

One last thing we need to do here. We're using one composer package called Dotenv. If you're using a local dev server, then you can install it locally and then sync it up with rsync or your can run composer directly on your Hostinger space by SSHing in. The command is:

composer require vlucas/phpdotenv

Now let's test our API with Postman:

Testing the API with Postman

This example takes one of the images from this tutorial (1920x1080 jpeg) and reformats it to a width of 600 as webp with a quality of 75. If you get an image back, you're good to go!


Building The CloudFlare Worker

Hopefully, you already have a free CloudFlare account. If not, it's super easy to setup. I also recommend using CloudFlare to host your DNS for your domain because you can automatically create a record for your worker and you also can take advantage of the many other free services CloudFlare provides for your domain.

The quickest way to get started building a worker is to use the wrangler tool from the command line. To install wrangler, run npm i -g wrangler. Next, navigate to the directory you want to create your project in and run npm create cloudflare@2. You may need to install some additional files, so select yes, then Type in your project name, I called it images-worker. For simplicity's sake, I've chosen not to use Typescript because this worker is very tiny and doesn't warrant the use of it. Next, choose yes for git as version control and no to deploy to CloudFlare:

Testing the API with Postman

Now we can open up our project in VS Code:

cd images-worker
code .

Ok, the first thing we need to do is add some vars to wrangler.toml. You can delete all the commented out stuff in there and add a [vars] section:

name = "images-worker"
main = "src/index.js"
compatibility_date = "2023-12-18"
[vars]
API_URL = "https://images-api.example.com"
IMAGES_DOMAIN = "example.com"
ACCESS_KEY="xt1LhW0OO0pmeVwW/grkV/zGaUsarWvA1OI7oCRcjTE="
CACHE_TTL=604800

The API_URL is the Hostinger subdomain we created, IMAGES_DOMAIN is the allowed origin for our images. If you have more than one, you could use multiple vars or use a comma-delimited list and then split it in code. The ACCESS_KEY is our API key we generated. Make sure it matches the one in your PHP .env file. Lastly, CACHE_TTL is how long we want images to be valid for in the CloudFlare cache. I've set mine to 604800, which is seven days.

The only other file we need to edit is index.js, which will be our main route handler for the worker:

addEventListener('fetch', (event) => {
	event.respondWith(handleRequest(event.request, event));
});
/**
 * Fetch and log a request
 * @param {Request} request
 */
async function handleRequest(request, event) {
	// Construct the cache URL and key
	let cacheUrl = new URL(request.url);
	let cacheKey = new Request(cacheUrl.toString(), request);
	let cache = caches.default;
	// Check for the cached response
	let cachedResponse = await cache.match(cacheKey);
	if (cachedResponse) {
		console.log(`Cache hit for: ${request.url}`);
		return cachedResponse;
	}
	// Parse request URL to get access to query string
	let url = new URL(request.url);
	// Cloudflare-specific options are in the cf object.
	let options = { cf: { image: {} } };
	// Copy parameters from query string to request options.
	if (url.searchParams.has('fit')) options.cf.image.fit = url.searchParams.get('fit');
	if (url.searchParams.has('width')) options.cf.image.width = url.searchParams.get('width');
	if (url.searchParams.has('height')) options.cf.image.height = url.searchParams.get('height');
	if (url.searchParams.has('quality')) options.cf.image.quality = url.searchParams.get('quality');
	// Automatic format negotiation. Check the Accept header.
	const accept = request.headers.get('Accept');
	if (/image\/avif/.test(accept)) {
		options.cf.image.format = 'webp';
	} else if (/image\/webp/.test(accept)) {
		options.cf.image.format = 'webp';
	}
	// Get URL of the original (full size) image to resize.
	const imageURL = url.searchParams.get('image');
	if (!imageURL) return new Response('Missing "image" value', { status: 400 });
	try {
		// Validate the image URL
		const { hostname, pathname } = new URL(imageURL);
		if (!/\.(jpe?g|png|gif|webp)$/i.test(pathname)) {
			return new Response('Disallowed file extension', { status: 400 });
		}
		// Validate the image domain origin
		if (!hostname.endsWith(IMAGES_DOMAIN)) {
			return new Response('Unauthorized origin', { status: 403 });
		}
	} catch (err) {
		return new Response('Invalid "image" value', { status: 400 });
	}
	// Fetch the image from the PHP API
	const response = await fetch(`${API_URL}/resize?url=${imageURL}&w=${options.cf.image.width}&f=${options.cf.image.format}&q=${options.cf.image.quality}`, {
		headers: { 'X-Auth-Token': ACCESS_KEY },
	});
	// Use Response constructor to create a new response
	let newResponse = new Response(response.body, response);
	// Set Cache-Control header for the new response
	newResponse.headers.append('Cache-Control', `s-maxage=${CACHE_TTL}`);
	// Store the new response in cache
	event.waitUntil(cache.put(cacheKey, newResponse.clone()));
	return newResponse;
}

That's it for the worker. You can login to CloudFlare by running wrangler login and then deploy your worker by running wrangler deploy.

The last thing you'll want to do is map a custom subdomain to your worker. You can do that from the CloudFlare workers panel:

Testing the API with Postman

For sake of this tutorial, let's call our domain imager.example.com.


Setting Up the Next.js Image Loader

Now with everything in place, we can setup our Next.js project to use our little CDN. To do that we'll simply create a wrapper for the next/image component:

import React from 'react';
import Image from 'next/image';
import { StaticImageData } from 'next/image';
interface Props extends React.ComponentProps<typeof Image> {}
interface LoaderProps {
	src: string;
	width: number;
	quality?: number;
	className?: string;
}
const imageLoader = (props: LoaderProps) => {
	const isDev = process.env.NODE_ENV === 'development';
	const apiUrl = isDev ? 'https://localhost:9999' : 'https://imager.example.com';
	const { src, width, quality } = props;
	if (!src.startsWith('http')) return src;
	const resizerSrc = `${apiUrl}/?image=${src}&width=${width}&quality=${quality || 75}`;
	return resizerSrc;
};
const noLoader = (props: LoaderProps) => {
	const { src } = props;
	return src;
};
export default function Imager(props: Props) {
	const { width, quality, className = '' } = props;
	// We want to ingore the loader if the image is an SVG
	let isSvg = false;
	if (typeof props.src === 'string') {
		isSvg = props.src.endsWith('.svg');
	} else {
		const imageData = props.src as StaticImageData;
		isSvg = imageData.src.endsWith('.svg');
	}
	const isDev = process.env.NODE_ENV === 'development';
	// eslint-disable-next-line
	return <Image {...props} loader={!isDev && !isSvg ? imageLoader : noLoader} className={className} />;
}

Now we have a custom <Imager> component we can use in place of <Image>. If you're integrating this into an existing project and you already have lots of instances of <Image>, you can use VS Code's find/replace in files, which is a powerful tool. Optionally, you can setup the image loader from next.config.js and put this into a separate script.

Note that this will only work with external image files. This does not work with imported images. I would recommend you store all your images on a CDN. I have a couple of articles that delve deeply into setting up a CDN using AWS S3 and CloudFront. Links below.


By harnessing the power of PHP and CloudFlare workers, you've learned how to create a cost-effective, efficient image optimization and resizing service. This DIY approach not only saves money but also grants you greater control over your digital content. It's a testament to how, with the right tools and knowledge, you can effectively manage web resources and enhance user experience. Keep exploring and adapting new techniques to stay ahead in the dynamic world of web development. Happy optimizing!


Resources

  1. My Hostinger affiliate link
  2. GitHub Gist for additional PHP files
  3. How to Use AWS CloudFront to Create Your Own Free CDN
  4. How to Get a Custom Domain For Your Free CloudFront CDN
  5. # How to Get a Free NGINX/PHP-FPM Web Server

Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify!

Also, be sure to check out my new app called Snoozle! It's an app that generates bedtime stories for kids using AI and it's completely free to use!

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.