What is a virtual host? Why would I want one? It's a way of saving costs. At minimum, you'll only need one physical or virtual machine, one backend instance and Apache. So an Apache virtual host manages the routing of IP requests to your backend instance. Allowing you to host multiple distinct websites (domain name or white labels). To visitors, it would appear that a website is running on its own dedicated server but in reality, behind the scenes, Apache uses incoming requests to serve different resources depending on the requested website. Did that make sense? Perhaps an example will make it concrete.
Tools and Assumptions
The following were the tools I used to demonstrate this Apache virtual host example. For the backend instance, I've created a simple Spring Boot web app that just serves a static page with a bit of white label handling. I'm assuminng you know your way around Windows and Windows Susbsystem Linux. Furthermore, you should be able to create your own backend instance and run it in Ubuntu. I won't be explaining how to create my Spring Boot web app in detail but I'll tell you what the code does.
- A backend instance.
- Windows 11 Home 10.0.26200
- WSL - Ubuntu 22.04.3
- Apache/2.4.52 (Ubuntu)
- Good old vi and a text editor
- IntelliJ IDEA 2023.3.4 (Community Edition)
How Does a Virtual Host Work
This is what we are going to do. We will have two websites (i.e. white labels). Let's pretend our website serves Japan and the Philippines market. In order to save on costs, we only have one server for both markets. We let Apache process the requests and pass it along the correct path resource to our single backend instance. Sounds simple? It is once you get the hang of it. Painful in the beginning though LOL.
Backend Instance
As I have mentioned earlier, our backend instance is a Spring Boot web app. Here's the bit that we are interested in.
@Controller
public class WelcomeController {
@GetMapping("/welcome/{country}")
public String welcome(@PathVariable String country, Model model) {
switch (country) {
case "jp":
country = "Japan";
break;
default:
country = "Philippines";
}
model.addAttribute("country", country);
return "welcome";
}
}
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Welcome Page</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p th:text="|Welcome to the ${country} site of Codesamples!|" />
</body>
</html>
Can you tell what this piece of code does? Right, so as you can probably tell in the WelcomeController that we check the path requested and depending on the country, we serve the specific content based on the country.
Running the Backend
For sure, you can have your own backend written in any programming language. It is up to you. The below is an excerpt from the WSL shell. Once you have it running, you should be able to curl the localhost with port. In this case curl localhost:8080 will return a result in the shell.
jpllosa@g16-gaming:/workspace/web-thymeleaf/target$ java -jar web-thymeleaf-0.0.1-SNAPSHOT.jar
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-08-29T15:58:02.437+01:00 INFO 9632 --- [web-thymeleaf] [ main] n.c.w.WebThymeleafApplication : Starting WebThymeleafApplication v0.0.1-SNAPSHOT using Java 17.0.18 with PID 9632 (/workspace/web-thymeleaf/target/web-thymeleaf-0.0.1-SNAPSHOT.jar started by jpllosa in /workspace/web-thymeleaf/target)
2026-08-29T15:58:02.446+01:00 INFO 9632 --- [web-thymeleaf] [ main] n.c.w.WebThymeleafApplication : No active profile set, falling back to 1 default profile: "default"
2026-08-29T15:58:03.776+01:00 INFO 9632 --- [web-thymeleaf] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
2026-08-29T15:58:03.792+01:00 INFO 9632 --- [web-thymeleaf] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-08-29T15:58:03.792+01:00 INFO 9632 --- [web-thymeleaf] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
2026-08-29T15:58:03.827+01:00 INFO 9632 --- [web-thymeleaf] [ main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 1265 ms
2026-08-29T15:58:04.455+01:00 INFO 9632 --- [web-thymeleaf] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
2026-08-29T15:58:04.465+01:00 INFO 9632 --- [web-thymeleaf] [ main] n.c.w.WebThymeleafApplication : Started WebThymeleafApplication in 2.924 seconds (process running for 3.648)
2026-08-29T15:58:27.138+01:00 INFO 9632 --- [web-thymeleaf] [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-08-29T15:58:27.138+01:00 INFO 9632 --- [web-thymeleaf] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2026-08-29T15:58:27.139+01:00 INFO 9632 --- [web-thymeleaf] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
Apache Virtual Host Configuration
If you've been following my blogs, chances are you have done Apache Redirect Example. Excellent, if you have done it because you'll probably already have installed the Apache Rewrite module. If you have not, please head over to Apache Redirect Example and follow the Complex Apache Redirect section. And if you are too lazy to pop over there, then here's what I did. We also need the Proxy and HTTP Proxy modules. Restart Apache after it is installed. To check what modules you have, head over to /etc/apache2/mods-enabled directory.
jpllosa@g16-gaming:/etc/apache2/mods-enabled$ sudo a2enmod rewrite
[sudo] password for jpllosa:
Enabling module rewrite.
To activate the new configuration, you need to run:
systemctl restart apache2
jpllosa@g16-gaming:/etc/apache2/mods-enabled$ ls
access_compat.load authn_file.load autoindex.load env.load mpm_event.load rewrite.load
alias.conf authz_core.load deflate.conf filter.load negotiation.conf setenvif.conf
alias.load authz_host.load deflate.load mime.conf negotiation.load setenvif.load
auth_basic.load authz_user.load dir.conf mime.load reqtimeout.conf status.conf
authn_core.load autoindex.conf dir.load mpm_event.conf reqtimeout.load status.load
jpllosa@g16-gaming:/etc/apache2/mods-enabled$ sudo a2enmod proxy proxy_http
[sudo] password for jpllosa:
Enabling module proxy.
Considering dependency proxy for proxy_http:
Module proxy already enabled
Enabling module proxy_http.
To activate the new configuration, you need to run:
systemctl restart apache2
jpllosa@g16-gaming:/etc/apache2/mods-enabled$ ls
access_compat.load authn_file.load autoindex.load env.load mpm_event.load proxy_http.load setenvif.load
alias.conf authz_core.load deflate.conf filter.load negotiation.conf reqtimeout.conf status.conf
alias.load authz_host.load deflate.load mime.conf negotiation.load reqtimeout.load status.load
auth_basic.load authz_user.load dir.conf mime.load proxy.conf rewrite.load
authn_core.load autoindex.conf dir.load mpm_event.conf proxy.load setenvif.conf
jpllosa@g16-gaming:/etc/apache2/mods-enabled$ sudo systemctl restart apache2
Next is the per website Apache virtual host configuration. Create a similar file like below in your /etc/apache2/sites-available directory. Remember we are still in WSL Ubuntu land. You can name it whatever you like. I do hope it is descriptive though. Below is the virtual host configuration for the Japan website. You may skip copying the comments but I just straight up copied it over from 000-default.conf. Make another config file for the Philippines website. Should be the same except ServerName or you could say the "jp" bits become "ph".
jpllosa@g16-gaming:/etc/apache2/sites-available$ cat codesamples_jp.conf
<VirtualHost *:80>
# The ServerName directive sets the request scheme, hostname and port that
# the server uses to identify itself. This is used when creating
# redirection URLs. In the context of virtual hosts, the ServerName
# specifies what hostname must appear in the request's Host: header to
# match this virtual host. For the default virtual host (this file) this
# value is not decisive as it is used as a last resort host regardless.
# However, you must set it for any further virtual host explicitly.
ServerName codesamples.jp
ServerAdmin webmaster@localhost
#DocumentRoot /var/www/html
# Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
# error, crit, alert, emerg.
# It is also possible to configure the loglevel for particular
# modules, e.g.
#LogLevel info ssl:warn
ErrorLog ${APACHE_LOG_DIR}/codesamples-jp-error.log
CustomLog ${APACHE_LOG_DIR}/codesamples-jp-access.log combined
# For most configuration files from conf-available/, which are
# enabled or disabled at a global level, it is possible to
# include a line for only one particular virtual host. For example the
# following line enables the CGI configuration for this host only
# after it has been globally disabled with "a2disconf".
#Include conf-available/serve-cgi-bin.conf
ProxyPreserveHost On
ProxyPass / http://localhost:8080/welcome/jp
ProxyPassReverse / http://localhost:8080/welcome/jp
</VirtualHost>
jpllosa@g16-gaming:/etc/apache2/sites-available$ cat codesamples_ph.conf
<VirtualHost *:80>
# The ServerName directive sets the request scheme, hostname and port that
# the server uses to identify itself. This is used when creating
# redirection URLs. In the context of virtual hosts, the ServerName
# specifies what hostname must appear in the request's Host: header to
# match this virtual host. For the default virtual host (this file) this
# value is not decisive as it is used as a last resort host regardless.
# However, you must set it for any further virtual host explicitly.
ServerName codesamples.ph
ServerAdmin webmaster@localhost
#DocumentRoot /var/www/html
# Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
# error, crit, alert, emerg.
# It is also possible to configure the loglevel for particular
# modules, e.g.
#LogLevel info ssl:warn
ErrorLog ${APACHE_LOG_DIR}/codesamples-ph-error.log
CustomLog ${APACHE_LOG_DIR}/codesamples-ph-access.log combined
# For most configuration files from conf-available/, which are
# enabled or disabled at a global level, it is possible to
# include a line for only one particular virtual host. For example the
# following line enables the CGI configuration for this host only
# after it has been globally disabled with "a2disconf".
#Include conf-available/serve-cgi-bin.conf
ProxyPreserveHost On
ProxyPass / http://localhost:8080/welcome/ph
ProxyPassReverse / http://localhost:8080/welcome/ph
</VirtualHost>
What did you notice? How are we reaching our backend instance? As you can see, we are adding the path variable based on country or should I say Apache is adding it so our backend will know what content to serve.
Below is the trail of commands you'll possibly do. After you have two Apache virtual host config files, you'll need to enabled it (e.g. a2ensite) and disabled the default Apache config (e.g. a2dissite. Obviously, reload Apache to apply the changes you did.
jpllosa@g16-gaming:/etc/apache2/sites-available$ sudo cp codesamples_jp.conf codesamples_ph.conf
jpllosa@g16-gaming:/etc/apache2/sites-available$ sudo vi codesamples_ph.conf
jpllosa@g16-gaming:/etc/apache2/sites-available$ sudo a2ensite codesamples_jp.conf
Enabling site codesamples_jp.
To activate the new configuration, you need to run:
systemctl reload apache2
jpllosa@g16-gaming:/etc/apache2/sites-available$ sudo a2ensite codesamples_ph.conf
Enabling site codesamples_ph.
To activate the new configuration, you need to run:
systemctl reload apache2
jpllosa@g16-gaming:/etc/apache2/sites-available$ sudo a2dissite 000-default.conf
Site 000-default disabled.
To activate the new configuration, you need to run:
systemctl reload apache2
jpllosa@g16-gaming:/etc/apache2/sites-available$
Administrative Ubuntu Configuration
We're not done yet. See? I told you it was painful. Patience, we are nearly there. We are halfway there. What's left is getting traffic to route correctly. First is the /etc/hosts file. Update your hosts file as below. You can replace it with your own domain name as long as it matches your Apache virtual host configuration.
jpllosa@g16-gaming:/etc$ cat /etc/hosts
# This file was automatically generated by WSL. To stop automatic generation of this file, add the following entry to /etc/wsl.conf:
# [network]
# generateHosts = false
127.0.0.1 localhost
127.0.0.1 codesamples.jp
127.0.0.1 codesamples.ph
# The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
jpllosa@g16-gaming:/etc$
What this does is route requests to codesample.ph and codesample.jp to your Ubuntu box. Next is we need to know what the IP of our Linux box is. We need this IP to configure our Windows machine to route traffic to our Linux box. Here's my IP but it could be different in your system.
jpllosa@g16-gaming:/etc$ ip addr
1: lo: mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet 10.255.255.254/32 brd 10.255.255.254 scope global lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eth0: mtu 1492 qdisc mq state UP group default qlen 1000
link/ether 00:15:5d:8a:ce:ae brd ff:ff:ff:ff:ff:ff
inet 172.18.182.45/20 brd 172.18.191.255 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::215:5dff:fe8a:ceae/64 scope link
valid_lft forever preferred_lft forever
When requests come in, you can tail the Apache log files. Here's an example of where the log files are located.
jpllosa@g16-gaming:/var/log/apache2$ ls
access.log codesamples-jp-access.log error.log.10.gz error.log.14.gz error.log.5.gz error.log.9.gz
access.log.1 codesamples-jp-error.log error.log.11.gz error.log.2.gz error.log.6.gz other_vhosts_access.log
access.log.2.gz error.log error.log.12.gz error.log.3.gz error.log.7.gz
access.log.3.gz error.log.1 error.log.13.gz error.log.4.gz error.log.8.gz
Administrative Windows Configuration
Now that we got the IP, let's ping it to check connectivity. When we have connectivity, update the Windows hosts file. This will tell Windows where to route requests to our example domains. You'll need admin priviledges to update the hosts file.
C:\>ping 172.18.182.45
Pinging 172.18.182.45 with 32 bytes of data:
Reply from 172.18.182.45: bytes=32 time=1ms TTL=64
Reply from 172.18.182.45: bytes=32 time<1ms TTL=64
Reply from 172.18.182.45: bytes=32 time<1ms TTL=64
Reply from 172.18.182.45: bytes=32 time<1ms TTL=64
Ping statistics for 172.18.182.45:
Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),
Approximate round trip times in milli-seconds:
Minimum = 0ms, Maximum = 1ms, Average = 0ms
C:\Windows\System32\drivers\etc>type hosts
...snipped...
172.18.182.45 codesamples.jp
172.18.182.45 codesamples.ph
Apache Virtual Host Demonstration
Congratulations, you made it this far. Now, the time has come to see all the work we have done. Java web app is running? Check. Apache restarted with the correct configurations? Check. Ubuntu and Windows hosts file updated? Check. Very good! Let's hit the domains. Fire up your browser and go to your website. Here are a couple of screen grabs of the two example websites with corresponding network tab open and tailing the Apache access logs at the same time. As you will experience if you are trying this out, the logs move as soon as you request for your website. Which tells us we have routed things correctly since the request has reached it's target destination. Bravo, well done!
Apache Virtual Host Summary
There you have it. Was that fun for you? You have managed the routing of IP requests to your backend. You have hosted two distinct websites and the visitors wouldn't know it was only one Java web app working behind the scenes. Have a happy virtual hosting.

