Serve project images with jsDelivr and GitHub Pages
For a small public project, you can serve an image from GitHub through a commit-pinned jsDelivr URL. This walkthrough prepares a PNG, publishes the generated file, and adds an optional GitHub Pages copy with a JavaScript fallback. Both URLs will point to the same image.
Choose assets that fit the services
jsDelivr is a CDN service, not a JavaScript library to install. Its GitHub endpoint retrieves repository files directly; GitHub Pages is not a prerequisite. Pages and jsDelivr are alternative ways to deliver the files. Neither resizes or recompresses the PNG in this example.
Use this free image CDN setup for assets belonging to a public project, such as a screenshot in an open-source map demo. jsDelivr’s usage policy prohibits general-purpose file or media hosting, including storing an image-hosting site’s uploads. It explicitly recognizes legitimate projects such as apps and games with image assets. Give your project public documentation and an appropriate license, and only publish images you can distribute.
GitHub Pages is available for public repositories on GitHub Free. Its limits include a 1 GB published-site limit and a soft bandwidth limit of 100 GB per month. Pages also restricts using the service to run an online business, e-commerce site, or commercial SaaS. These services are a poor fit for private uploads or a general-purpose image-hosting business.
Prepare one PNG for publication
Start in a local checkout of your public project, on its main branch. The example repository is
called map-demo; replace YOUR-USERNAME and map-demo in URLs with your account and repository.
This walkthrough assumes a Yarn 4 project, Node.js 24 or later, a POSIX shell, and no existing site
in docs/. The local image workflow was tested with Node.js 26.8.1 and sharp 0.35.4 on Linux.
Install the sharp image processor and create the input and publication directories:
corepack yarn add --dev --exact sharp@0.35.4 &&
mkdir -p original-images docs/images
Put a still, 8-bit sRGB PNG screenshot in original-images/map.png. The HTML below assumes it is
640 × 360 pixels; change the HTML dimensions and alternative text to match your image.
Save this as optimize.cjs in the repository root. It processes .png files directly inside
original-images/ and writes matching filenames into a new version directory:
const fs = require('node:fs/promises')
const path = require('node:path')
const sharp = require('sharp')
async function optimizeImage(inputPath, outputPath) {
const image = sharp(inputPath)
const metadata = await image.metadata()
if (metadata.format !== 'png') throw new Error(`Expected a PNG: ${inputPath}`)
await image.png({ compressionLevel: 9, palette: false }).toFile(outputPath)
}
async function processDirectory(inputDir, outputDir) {
const files = await fs.readdir(inputDir)
// A published version must not be overwritten by a later run.
await fs.mkdir(outputDir)
for (const file of files) {
const inputPath = path.join(inputDir, file)
const stat = await fs.stat(inputPath)
if (!stat.isFile() || !file.endsWith('.png')) continue
await optimizeImage(inputPath, path.join(outputDir, file))
}
}
processDirectory('original-images', 'docs/images/v1').catch((error) => {
console.error(error)
process.exitCode = 1
})
Run it once:
corepack yarn node optimize.cjs
Open docs/images/v1/map.png and compare it with the original. The script preserves its dimensions
and uses PNG compression without palette quantization. sharp normally converts to sRGB and removes
metadata. A smaller file is not guaranteed: compare sizes before adopting the output. Setting PNG
quality would enable palette quantization and could lose colors; see sharp’s
output options.
A rerun fails if docs/images/v1 already exists, leaving that version intact. A corrupt input also
exits with an error; it can leave a partly written new directory. Do not publish that directory.
After fixing the input, remove only the failed, unpublished output directory before retrying.
Commit the generated image
Check that docs/images/v1/map.png is the actual PNG, not a Git LFS pointer. Commit the generated
asset itself so jsDelivr can retrieve it. From the repository root, with no unrelated changes staged:
git add docs/images/v1/map.png &&
git commit -m "Add versioned map screenshot" &&
git push origin main &&
git rev-parse HEAD
Keep the full commit hash printed by the final command. Below, COMMIT-SHA means that hash, from
the commit containing the PNG. Keep optimize.cjs, package.json, and yarn.lock with your
project’s source too; node_modules/ does not belong in the commit.
Use the commit-pinned jsDelivr URL
Your image URL is:
https://cdn.jsdelivr.net/gh/YOUR-USERNAME/map-demo@COMMIT-SHA/docs/images/v1/map.png
Open it in a browser after replacing the placeholders. It should display the generated PNG. No jsDelivr account or Pages deployment is required. This is the documented GitHub URL format.
Use a full commit hash rather than main, latest, or an omitted version. jsDelivr
caches static versions and commit URLs permanently
and gives them long-lived cache headers. Publish changed images at a new commit and update the URL;
deleting the original from GitHub does not reliably withdraw a cached copy.
Add GitHub Pages as an optional fallback
You can use the jsDelivr URL in your own site immediately. To give this example a second delivery
path, publish docs/ through Pages. Save this page as docs/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Map demo</title>
<script src="image.js" defer></script>
</head>
<body>
<h1>Map demo</h1>
<img id="my-image" alt="Screenshot of the map demo" width="640" height="360" />
<noscript>Enable JavaScript to load this image demo.</noscript>
</body>
</html>
Reliability and fallback
Save the following as docs/image.js. Replace YOUR-USERNAME and COMMIT-SHA, and replace
map-demo if your repository has another name. Use the image commit hash from the previous step;
the page and script can be committed later.
function loadImage(imageElement, primarySrc, fallbackSrc) {
imageElement.onerror = function () {
imageElement.onerror = null
console.warn('Primary CDN failed, using fallback')
imageElement.src = fallbackSrc
}
imageElement.src = primarySrc
}
const img = document.getElementById('my-image')
if (!(img instanceof HTMLImageElement)) throw new Error('Missing image element: my-image')
loadImage(
img,
'https://cdn.jsdelivr.net/gh/YOUR-USERNAME/map-demo@COMMIT-SHA/docs/images/v1/map.png',
'https://YOUR-USERNAME.github.io/map-demo/images/v1/map.png',
)
The handler switches to Pages once on an image error. If both origins fail, it leaves the image’s alternative text available and stops retrying. It has no timeout for a request that keeps waiting, and the two paths still share GitHub as a source. This is a limited fallback, not an availability guarantee.
Create an empty docs/.nojekyll file to
disable Jekyll processing,
then publish the page and script:
touch docs/.nojekyll &&
git add docs/.nojekyll docs/index.html docs/image.js &&
git commit -m "Add image demo page" &&
git push origin main
In the repository’s Settings, open
Pages. Under Build and deployment,
set Source to Deploy from a branch,
choose main and /docs, then click Save. GitHub’s
publishing guide
describes these controls and the deployment run to check if publication fails. Branch publishing
uses a GitHub-managed Actions workflow; you do not need a custom optimization workflow.
Wait for a successful deployment before opening https://YOUR-USERNAME.github.io/map-demo/.
This assumes the default project-site domain, with no custom domain. The paths differ because
Pages publishes the contents of docs/, while jsDelivr reads from the repository root:
| Location | Image path |
|---|---|
| Committed file | docs/images/v1/map.png |
jsDelivr, after @COMMIT-SHA/ | docs/images/v1/map.png |
Pages, after /map-demo/ | images/v1/map.png |
For an update, change the optimizer’s output directory to docs/images/v2, generate and inspect
the new image, and commit it. Use that new commit hash and v2 path in the script’s CDN URL, and
v2 in its Pages URL. Deploy the new asset before switching consumers. Retain v1 for old links.
Pages does not pin a URL to a Git commit: the version directory stays stable only if you keep its
contents unchanged.
Testing your setup
First open both image URLs directly. A 404 usually means the file was not committed at the pinned revision, the path or capitalization differs, or Pages has not deployed the selected folder yet. Check that each response is a PNG, not an HTML error page.
On the demo page, use your browser’s network inspector to confirm the image request and its dimensions. Block the exact jsDelivr image URL and reload: the Pages request should succeed. Then block both URLs and reload: there should be one attempt at each, with the image’s alternative text still present. Unblock them afterward. Test the image’s loading behavior, not just a successful page response.
Security
CORS headers
An ordinary cross-origin <img> can display without opting into CORS. Reading its pixels through
canvas has additional CORS requirements.
This demo only displays the image. CORS is not authentication and does not restrict who can download
a public asset.
Prevent hotlinking
JavaScript on your page cannot stop someone else embedding the public image URL. Keep private images out of this workflow. For access control, use storage and a delivery service that can enforce authorization or signed URLs.
When you need more than one image size
This example publishes one PNG at its original dimensions. Adding srcset or a <picture> element
does not generate smaller images or AVIF/WebP files: those files must be produced and committed
first. A responsive fallback also needs to clear failed srcset and <source> candidates before
changing src, so the simple handler above is intentionally for one <img> without those candidates.
For dynamic sizes or formats, consider an image transformation service such as
Transloadit’s Image Processing API.
