ExpressionEngine's file manager and image manipulation pipeline lean entirely on PHP's GD library. The engine reads image dimensions, generates thumbnails for the Control Panel, and produces named manipulations (resize, crop, rotate, and format conversion) at upload time or on first request. On a managed shared account, none of this touches the server binaries you can reconfigure — everything depends on the PHP version bound to your domain, the memory ceiling that version enforces, the writable state of your upload directories, and the MIME table ExpressionEngine consults before it accepts a file. When image handling misbehaves, the fault almost always traces to one of those four account-level factors rather than to anything in the web server itself.
The practical goal on shared hosting is to shrink stored bytes and served bytes without asking for privileges you do not have. That means enabling modern formats where ExpressionEngine already supports them, keeping thumbnail and manipulation directories writable and pruned, giving GD enough memory to process large uploads, and pushing the finished static files off your origin to a CDN. Each of those is reachable through the Control Panel UI, the CMS AdminCP, .htaccess, and .user.ini.
Why thumbnails and manipulations fail, and how to keep the directories healthy
ExpressionEngine creates a thumbnail for every image uploaded through the file manager and stores it in a _thumbs subfolder inside the upload directory. Named image manipulations you define under the upload directory settings are written to their own sibling subfolders. When you see the error "Thumbnail could not be created for the image. Please make sure the thumbnail directory is writable", the upload directory or its _thumbs child does not have permissions PHP can write to, or the parent directory is missing entirely after a migration.
Fix this in cPanel File Manager (or DirectAdmin's File Manager) rather than over SSH. Navigate to your upload path — commonly public_html/images/uploads/ — select the folder, and use the Permissions dialog to set it to 0755. On CloudLinux with a suEXEC-style PHP handler, files run as your own user, so 0755 for directories and 0644 for files is correct; you do not need world-writable 0777, and setting it can actually trip security scanners. Confirm the _thumbs subfolder exists and shares those permissions. If it is absent, create it manually and let ExpressionEngine populate it on the next upload.
In the AdminCP, review each upload location under Files → Upload Directories. Each directory carries a set of image manipulations with a Short Name, target width and height, and an action (constrain, crop, or resize). Every manipulation multiplies the files stored per upload — a directory with five manipulations and a thumbnail turns one photo into seven files on disk. On a constrained shared quota this adds up quickly, so keep only the manipulations your templates actually reference with tags like {news_image:small} or {file_size:small}. Delete unused manipulation definitions, then remove the orphaned subfolders in File Manager to reclaim inode count, which many shared plans cap more aggressively than raw disk space.
Enabling WebP and AVIF conversion inside ExpressionEngine
ExpressionEngine 7 exposes format conversion directly through the File fieldtype's manipulation modifiers. A template can request {news_image:webp} to emit a WebP copy, and version 7 adds {news_image:avif} for AVIF, with the resize modifiers chainable onto either. Version 6 supports the :webp modifier but not AVIF. These conversions are performed by GD at request time and cached to disk, so the browser receives a smaller file while your editors keep uploading familiar JPG and PNG originals.
Two account-level conditions must hold for this to work. First, your PHP version needs GD compiled with the relevant encoders. Set the version under cPanel's MultiPHP Manager or in Select PHP Version (the CloudLinux PHP Selector) — choose PHP 8.1 or newer, because WebP encoding via imagewebp() is reliable there and AVIF encoding via imageavif() requires PHP 8.1 or later with a suitably recent GD build. In the PHP Selector's Extensions tab, confirm gd is enabled. You can verify what your account actually has by creating a temporary phpinfo.php in public_html, loading it, checking the GD section for "WebP Support" and "AVIF Support", and then deleting the file immediately.
Second, uploading WebP originals (rather than converting on the fly) has historically been blocked by ExpressionEngine's MIME whitelist. If editors need to upload .webp or .avif source files, the accepted approach is to extend the recognized MIME types through the user config file at system/user/config/config.php, editing it in File Manager:
$config['mime_whitelist_additions'] = 'image/webp,image/avif';Set the affected upload directory's Allowed file types to All file types if Images only continues to reject the new formats, and make sure the _thumbs directory is writable so ExpressionEngine can still build a preview. For most sites the cleaner route is to keep uploading JPG/PNG originals and let the :webp or :avif template modifiers do the conversion, since that avoids the upload-side friction entirely and produces cached derivatives you can serve to modern browsers.
Giving GD the memory and upload headroom it needs
GD loads the entire uncompressed bitmap into RAM while it works. A 4000×3000 JPEG that occupies 3 MB on disk expands to roughly 36 MB in memory during processing, and generating several manipulations in one request compounds that. When PHP's memory ceiling is too low, ExpressionEngine either produces a truncated thumbnail or logs a fatal Allowed memory size exhausted entry. Check your account's error_log — it appears in the directory where the script ran, usually public_html or the ExpressionEngine system folder — to confirm memory rather than permissions is the cause.
Raise the limits without touching server config by placing a .user.ini file in public_html:
memory_limit = 256M
upload_max_filesize = 32M
post_max_size = 40M
max_execution_time = 120The PHP Selector's Options tab exposes the same directives through a UI and is the more reliable path on CloudLinux, since account-level .user.ini values are capped by the host's ceiling. If the Selector shows a maximum below what you need, that ceiling is set at the plan level and no local file will override it — that is the signal to move heavy processing off the request. Note that .user.ini changes are cached and take effect after the user_ini.cache_ttl window (300 seconds by default), so wait a few minutes before re-testing rather than assuming the change failed.
Keep upload_max_filesize aligned with the AdminCP's own per-directory size limits under Files → Upload Directories; if PHP allows 32 MB but the directory caps at 2 MB, editors hit the smaller number first. Encouraging editors to upload reasonably sized originals — say, a 2500px longest edge — dramatically lowers peak memory and speeds every subsequent manipulation.
Offloading static images to a CDN and browser cache
Once ExpressionEngine has written thumbnails and format derivatives to disk, serving them from your origin on every page view wastes bandwidth and LiteSpeed worker time. Because ExpressionEngine stores manipulations as ordinary files under predictable paths, a pull-CDN maps cleanly onto them: point the CDN at your images/uploads/ tree as the origin, then rewrite the delivered URLs to the CDN hostname. If your CDN provider gives you a CNAME such as cdn.example.com, add that record through your DNS zone in cPanel or DirectAdmin, and set ExpressionEngine's upload directory URL field to the CDN hostname while leaving the Server Path pointing at the real local directory. That single AdminCP change makes the CMS emit <img> tags that reference the CDN while the files continue to be written locally where GD can reach them.
Reinforce this with caching headers so both the CDN and end-user browsers hold copies. Add the following to the .htaccess in public_html; LiteSpeed honors standard Apache directives:
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/avif "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(webp|avif|jpe?g|png|gif)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>Long cache lifetimes are safe here because ExpressionEngine's manipulation filenames change when the source or manipulation definition changes, so stale delivery is not a practical risk. If your plan includes the LiteSpeed Cache integration, its built-in CDN mapping can perform the URL rewrite without editing the upload directory URL, but the AdminCP approach above works on any account and keeps the configuration inside ExpressionEngine where it is easy to audit.
Object storage as a primary filesystem is out of reach here — ExpressionEngine's core file handling expects a local, GD-readable path, and mounting an S3-compatible bucket as the live upload directory requires a stream wrapper and server-level configuration you cannot install as an unprivileged user. Treat the CDN as a delivery cache in front of local files rather than as the backing store, and the media pipeline stays fast, cheap, and fully within your control.