4 minute read

I want to be able to load local images when the site is served locally. However, the URL format file:///Users/<username>/archive/image/20260218184820.png doesn’t work.

Why <img src="file:///Users/..."> won’t load?

Your page is being served over HTTP (e.g. http://localhost:4000). Modern browsers block pages from http(s)://… from loading local files via file://… for security (“cross-origin” / local file access). So even though the HTML is “correctly parsed”, the browser refuses to fetch it.

That means: a file:// URL will not work on a website (local dev server or deployed site). It only works when the page itself is also opened via file:// (and even then, policies vary).

Method 1: Copying files to project assets

The simplest method is to copy the images to <project-root>/assets/images/ and then refer to them using the standard syntax:

---
gallery:
  - url: /assets/images/20260218184820.png
    image_path: /assets/images/20260218184820.png
---

![alt]({{ site.url }}{{ site.baseurl }}/assets/images/filename.jpg)

<img src="https://josephtesfaye.com/josephs-blog/assets/images/filename.jpg" alt="">

{% include gallery %}

However, this has the overhead of managing files manually, which can be tedious. The following methods give you better experience.

  1. Create a symlink to the target data inside the site root. Say, if the local images reside in ~/Downloads/temp/archive/image/foo/ you can create a symlink to it under <project-root>/_site/assets/archive/image/foo:

    cd <project-root>/_site/
    mkdir -p assets/archive/image/
    ln -s "~/Downloads/temp/archive/image/foo" "assets/archive/image/foo"
    
  2. Then you can refer to the images using the symlink like the following:

    ---
    gallery:
      - url: /assets/archive/image/foo/20260218184820.png
        image_path: /assets/archive/image/foo/20260218184820.png
      - url: /assets/archive/image/foo/20260218184820.png
        image_path: /assets/archive/image/foo/20260218184820.png
    ---
    
    ![]({{site.url}}{{site.baseurl}}/assets/archive/image/foo/20260218184820.png)
    
    {% include gallery %}
    
  3. The symlinks created this way are cleared every time the site is re-built. To make them persist we can write a plugin _plugins/symlink_external_assets.rb to create them automatically according to the following front matter of a post:

    ---
    symlinks:
      - /assets/archive/image/foo ~/Downloads/temp/archive/image/foo
      - /assets/archive/video/foo ~/Downloads/temp/archive/video/foo
    ---
    

    symlink_external_assets.rb:

    require 'fileutils'
    require 'shellwords'
    
    Jekyll::Hooks.register :site, :post_read do |site|
      return if Jekyll.env == 'production'
    
      # Store the intended symlinks so we can recreate them in the _site folder later
      site.config['dynamic_symlinks'] ||= {}
    
      docs = site.pages + site.collections.values.flat_map(&:docs)
      docs.each do |doc|
        next unless doc.data['symlinks'].is_a?(Array)
    
        doc.data['symlinks'].each do |symlink_def|
          next unless symlink_def.is_a?(String)
    
          parts = Shellwords.split(symlink_def)
          next unless parts.size == 2
    
          link_path_raw, target_path_raw = parts
    
          target_path = File.expand_path(target_path_raw)
          relative_link_path = link_path_raw.sub(%r{^/}, '')
          link_path = File.join(site.dest, relative_link_path)
    
          # Verify target exists
          unless File.exist?(target_path)
            Jekyll.logger.warn "Symlink Plugin:", "Skipped. Target does not exist: #{link_path} -> #{target_path}"
            next
          end
    
          # Save for the post_write phase
          site.config['dynamic_symlinks'][relative_link_path] = {
            target: target_path,
            link: link_path
          }
        end
      end
    end
    
    # Inject the symlinks directly into the final build folder
    Jekyll::Hooks.register :site, :post_write do |site|
      return if Jekyll.env == 'production'
    
      symlinks = site.config['dynamic_symlinks'] || {}
      symlinks.each do |relative_path, link|
        target_path = link[:target]
        link_path = link[:link]
        needs_creation = false
    
        # If the link already exists but is broken, recreate it. If it's not broken
        # or is a file or directory, leave it. Otherwise, create the link.
        if File.symlink?(link_path)
          if !File.exist?(link_path)
            File.unlink(link_path)
            needs_creation = true
          elsif File.readlink(link_path) != target_path # Link has valid target
            Jekyll.logger.warn "Symlink Plugin: Symlink already exists: #{link_path}"
          end
        elsif !File.exist?(link_path)
          needs_creation = true
        else                        # Link path is taken by a file or directory
          Jekyll.logger.warn "Symlink Plugin: Symlink path is taken: #{link_path}"
        end
    
        if needs_creation
          FileUtils.mkdir_p(File.dirname(link_path))
          begin
            File.symlink(target_path, link_path)
            # Jekyll.logger.info "Symlink Plugin:", "Symlink created: #{link_path} -> #{target_path}"
          rescue => e
            Jekyll.logger.warn "Symlink Plugin:", "Failed to create symlink #{link_path}: #{e.message}"
          end
        end
      end
    end
    

Method 3: Serving the files locally with a web server

For example, you can serve the images locally with Python’s built-in web server:

  1. Serve your image folder

    cd ~/Downloads/temp/archive/image/foo/
    python3 -m http.server 8123 --bind 127.0.0.1
    

    If you want the server to run in the background:

    nohup python3 -m http.server 8123 --bind 127.0.0.1 >/tmp/imgserver.log 2>&1 &
    

    To stop it later:

    lsof -iTCP:8123 -sTCP:LISTEN
    kill <PID>
    
  2. Use that URL in your post

    ![](http://127.0.0.1:8123/foo/20260218184820.png)
    

You can serve from the directory ~/Downloads/temp/archive/image/foo/ but use a different mount path in the URL to refer to that directory, e.g., http://localhost:8123/igps/20260218184820.png. To achieve this we can write and run this python script:

cd ~/Downloads/temp/archive/image/foo/
python3 server.py

server.py:

#!/usr/bin/env python3
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
import os
import sys

# Directory you want to expose
ROOT = Path(os.path.expanduser("~/Downloads/temp/archive/image/foo")).resolve()

MOUNT = "/igps/"
HOST = "127.0.0.1"
PORT = 8123


class MountHandler(SimpleHTTPRequestHandler):
    def translate_path(self, path: str) -> str:
        """
        Map URLs under /igps/... to files under ROOT/...
        Reject everything else with a 404.
        """
        # Strip query/fragment
        path = path.split("?", 1)[0].split("#", 1)[0]

        if not path.startswith(MOUNT):
            # Send 404 for non-mounted paths
            return str(ROOT / "__nonexistent__")

        rel = path[len(MOUNT):]  # e.g. "20260218184820.png"
        # Normalize and prevent path traversal
        rel_path = Path(rel)
        full = (ROOT / rel_path).resolve()

        if ROOT not in full.parents and full != ROOT:
            return str(ROOT / "__nonexistent__")

        return str(full)

    def log_message(self, fmt, *args):
        # optional: quieter logs
        sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0],
                                              self.log_date_time_string(),
                                              fmt % args))


if __name__ == "__main__":
    if not ROOT.exists():
        print(f"ERROR: directory does not exist: {ROOT}", file=sys.stderr)
        sys.exit(1)

    httpd = ThreadingHTTPServer((HOST, PORT), MountHandler)
    print(f"Serving {ROOT} at http://{HOST}:{PORT}{MOUNT}")
    httpd.serve_forever()

Comments