Forwarding a Vagrant-based Jekyll dev server to its host

8 minute read

Forwarding port 80 or 22 from Vagrant virtual machines to their host system is fairly simple. Piping non-privileged ports outside the VM–from, say, a development HTTP server–requires not much extra work. Yet, it’s not always obvious how to do this. In this post, I explain how a Jekyll development HTTP server running within a Vagrant VM can allow connections from its host and why the configuration works.

Information flowing from Jekyll out into a Vagrant VM and then
through a forwarding tunnel into a Firefox logo representing a browser.
Piping a Jekyll site from a Vagrant VM out to its host system.
Image credits: Vagrant logo, Jekyll logo, Firefox logo

I was recently testing how to set up and build a Jekyll-based static site by running everything in a Vagrant VM. Since this was a test, I didn’t want to set up nginx, Apache, or reverse proxies; I only needed to connect to the development HTTP server. By default, Jekyll’s built-in HTTP server listens on port 4000. Thus, to connect to that port from a browser running on the VM’s host system, I only needed to forward port 4000 in my Vagrant config, right? Well, that’s part of the story, yes, but it’s not the full story.

Building background knowledge

Let’s build up some knowledge before wiring everything together.

Find the port number

After setting up a Jekyll site, one of the first things you want to do is verify that it works. Thus, you want to start the dev HTTP server and point your browser at http://127.0.0.1:4000/. This is something that running jekyll serve tells you:

$ jekyll serve
Configuration file: /home/cochrane/hello-jekyll/_config.yml
            Source: /home/cochrane/hello-jekyll
       Destination: /home/cochrane/hello-jekyll/_site
 Incremental build: disabled. Enable with --incremental
      Generating...
       Jekyll Feed: Generating feed for posts
                    done in 1.392 seconds.
 Auto-regeneration: enabled for '/home/cochrane/hello-jekyll'
    Server address: http://127.0.0.1:4000/
  Server running... press ctrl-c to stop.

That’s the first bit of knowledge to collect: the server runs on port 4000.

Forward the port to the host

The thing is, this server is running on localhost within the virtual machine. Trying to connect to this address from the VM’s host won’t work. This is because the connection would access localhost on the host, which is different. We solve this problem by forwarding the port from the guest system to the host system.

Forwarding ports in Vagrant is simple enough: add the forwarded_port identifier to the VM’s network settings. The canonical example, e.g. from the Vagrant docs, forwards port 80 from the guest to port 8080 on the host. In other words, you set something like this in the relevant part of your Vagrantfile:

    config.vm.network "forwarded_port", guest: 80, host: 8080

That’s the next bit of knowledge: add the forwarded_port option to the network setting in the Vagrantfile, and point the guest port to an unprivileged port number1 on the host.

How do we set this up for our Jekyll example? We need to pipe port 4000 to another number. Let’s use 4400 here so that it doesn’t clash with a separate Jekyll service which might be running on port 4000 on the host. Thus, we add this line to our Vagrantfile:

    config.vm.network "forwarded_port", guest: 4000, host: 4400, id: "jekyll"

Note that I’ve added an ID to the configuration so that it’s more obvious to a human what service should run on this port.

Learning to listen

So far, so good. Starting (or restarting) the Vagrant VM and running jekyll serve within it, we should be able to see the site in a browser at http://localhost:4400, i.e. on the port we opened up on the host.

But you’ll find that this won’t work. A browser will tell you that the connection was reset. Using curl from the host system gives similar output:

$ curl -v http://localhost:4400/
*   Trying 127.0.0.1:4400...
* Connected to localhost (127.0.0.1) port 4400 (#0)
> GET / HTTP/1.1
> Host: localhost:4400
> User-Agent: curl/7.88.1
> Accept: */*
>
* Recv failure: Connection reset by peer
* Closing connection 0
curl: (56) Recv failure: Connection reset by peer

What’s going wrong?

To work that out, we need to do some debugging. Let’s check that the port is being forwarded correctly. Having a look for it on the host with netstat reveals:

$ netstat -tulpen | grep 4400
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
tcp        0      0 0.0.0.0:4400            0.0.0.0:*               LISTEN 1000       7916524    -

Ok, the port’s there, and it looks like we should be able to access it.

Checking the ports inside the guest hints at the problem’s cause:

$ netstat -tulpen
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       User       Inode      PID/Program name
tcp        0      0 127.0.0.1:4000          0.0.0.0:*               LISTEN      1001       16842      -
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      0          1985       -
tcp6       0      0 :::22                   :::*                    LISTEN      0          1987       -
<snip>

Note how port 22 has been configured, and that it is different to port 4000’s configuration. Namely, the local address is 0.0.0.0 for port 22, but it’s set to 127.0.0.1 for port 4000. Does that cause the connection reset failure? Put very simply, yes.

The local addresses shown here are the addresses that the operating system will allow connections on. There’s also a subtlety that’s not obvious: 0.0.0.0 is not an IP address. This is a shorthand, telling the operating system to listen for connections on any IPv4 address with the given port number. Thus, while we see the virtual machine listening for connections on port 22 from any address, it’s only listening for connections to port 4000 on 127.0.0.1. This is a specific address, and it’s not the one the host is trying to connect to via the forwarded port.

Furthermore, 127.0.0.1 is a loopback address.2 This is completely internal,3 and hence there is no outside network connection. No traffic to this address will leave the computer. So, a service listening on 127.0.0.1 will only answer to connection requests from the computer running the service. No wonder we couldn’t access it from the host! There was never the possibility of being able to connect to it from the outside. That’s why the connection was reset when accessing http://127.0.0.1:4000.

How do we fix this problem? The solution is to bind the HTTP server to 0.0.0.0, thus making it listen for connections from any address.

That’s the last bit of knowledge we need: bind the service to 0.0.0.0.4

There are more subtleties to this topic. For a lot more detail and excellent explanations, have a look at the article 127.0.0.1 vs 0.0.0.0: What They Mean, When to Use Each, and How to Debug Binding Bugs.

Host binding

So, how do we get Jekyll to bind to 0.0.0.0? The answer is to pass the bind address to jekyll serve via the --host option:

$ jekyll serve --host 0.0.0.0
Configuration file: /home/cochrane/hello-jekyll/_config.yml
            Source: /home/cochrane/hello-jekyll
       Destination: /home/cochrane/hello-jekyll/_site
 Incremental build: disabled. Enable with --incremental
      Generating...
       Jekyll Feed: Generating feed for posts
                    done in 1.46 seconds.
 Auto-regeneration: enabled for '/home/cochrane/hello-jekyll'
    Server address: http://0.0.0.0:4000/
  Server running... press ctrl-c to stop.

Note that the log message Server address: http://0.0.0.0:4000/ shown above is, technically, incorrect. This is not an address, hence you should not point your browser at it. It might work, but don’t count on it.

With the service bound to 0.0.0.0, you’ll now find that the service is listening on all addresses on port 4000 within the VM:

$ netstat -tulpen
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       User       Inode      PID/Program name
tcp        0      0 0.0.0.0:4000            0.0.0.0:*               LISTEN      1001       15737      -

<snip>

and that a connection via the forwarded port to this service is now possible:

curl -v http://127.0.0.1:4400
*   Trying 127.0.0.1:4400...
* Connected to 127.0.0.1 (127.0.0.1) port 4400 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:4400
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Etag: 4065fa-1295-69849bfa
< Content-Type: text/html; charset=utf-8
< Content-Length: 4757
< Last-Modified: Thu, 05 Feb 2026 13:32:42 GMT
< Cache-Control: private, max-age=0, proxy-revalidate, no-store, no-cache, must-revalidate
< Server: WEBrick/1.9.2 (Ruby/3.4.8/2025-12-17)
< Date: Thu, 05 Feb 2026 13:36:56 GMT
< Connection: Keep-Alive

<snip lots of HTML>

* Connection #0 to host 127.0.0.1 left intact

Thus, pointing a browser on the host at the address http://localhost:4400 will display the development Jekyll site that we wanted to see. Great!

A general situation

Note that this is not specific to Jekyll, nor to Vagrant. Other virtualisation technologies and HTTP servers providing a service on localhost by default behave this way. For instance, it could be that you’re trying to connect to a development Django web server running inside a Docker container. Again, the dev HTTP server must be bound to 0.0.0.0 so that the host system can connect to any forwarded ports.

Another thing learned!

I hope this helps someone confronted with such a situation in the future! For me, it was nice to learn the differences between 127.0.0.1, localhost and 0.0.0.0: my previous conceptual model dumped them all into the same pot, which was incorrect. Now I understand their meaning better, and how and where to use them.

  1. What I mean by an unprivileged port number is one whose use or configuration does not require superuser privileges. These are sometimes also called unrestricted ports or registered ports

  2. Note that the address 127.0.0.1 is the loopback address on IPv4. On IPv6, the loopback address is ::1. The name localhost could be both. For instance, if you try to connect to http://localhost in a browser, it will likely try ::1 first before possibly falling back to 127.0.0.1. This is not behaviour one can rely on. 

  3. In this case, to the VM. 

  4. The IPv6 equivalent of this shorthand is ::

Support

If you liked this post, please buy me a coffee!

buy me a coffee logo