Forwarding a Vagrant-based Jekyll dev server to its host
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.

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.
-
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. ↩
-
Note that the address
127.0.0.1is the loopback address on IPv4. On IPv6, the loopback address is::1. The namelocalhostcould be both. For instance, if you try to connect tohttp://localhostin a browser, it will likely try::1first before possibly falling back to127.0.0.1. This is not behaviour one can rely on. ↩ -
In this case, to the VM. ↩
-
The IPv6 equivalent of this shorthand is
::. ↩
Support
If you liked this post, please buy me a coffee!