SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Apache reverse proxy with mod_proxy on Ubuntu

Run Apache as a reverse proxy on Ubuntu, then read a backend log to see what ProxyPreserveHost, X-Forwarded-For and X-Forwarded-Proto really deliver.

What an Apache reverse proxy does

An Apache reverse proxy accepts requests on the public ports and passes them to an app that listens only on the server itself. In this guide that app is on 127.0.0.1:8080. The setup on Ubuntu is short. You enable mod_proxy, mod_proxy_http and mod_headers, then write one virtual host with ProxyPass and ProxyPassReverse. The part people get wrong is what the app receives. The Host header changes on the way through, and the client address and the scheme move into extra headers. So this guide builds a small backend that logs those headers, and you watch each setting change them.

The commands work on Ubuntu 24.04 and 26.04. As of October 2026, packages.ubuntu.com lists apache2 based on upstream 2.4.58 for 24.04 and 2.4.66 for 26.04. The newest upstream release is 2.4.69, from 1 October 2026, but your server runs the packaged version. Check which one you have:

sudo apt update
sudo apt install -y apache2 curl
apache2 -v

The first line of apache2 -v names the version. The Ubuntu suffix after the upstream number changes with security updates, so do not expect it to match anyone else's. Both releases are newer than 2.4.47, which matters for WebSocket later.

If you want PHP pages served by Apache itself rather than a proxy, a LAMP stack on Ubuntu 24.04 is the better starting point. If you have not chosen a proxy yet, nginx vs Caddy vs Traefik as a reverse proxy compares the other common options. Apache is a sensible choice when it already serves other sites on the same box.

Enable mod_proxy, mod_proxy_http and mod_headers

sudo a2enmod proxy proxy_http headers
sudo systemctl restart apache2
sudo apache2ctl -M | grep -E 'proxy|headers'

a2enmod creates symlinks in /etc/apache2/mods-enabled. Running it for a module that is already enabled is not an error. The module list should now include proxy_module, proxy_http_module and headers_module.

You need more than mod_proxy. The Apache docs say a proxy needs mod_proxy plus at least one scheme module, and mod_proxy_http is the scheme module for http:// backends. mod_headers provides RequestHeader, which you use below for X-Forwarded-Proto. Ubuntu's own Apache documentation follows a2enmod with a restart, not a reload, so this guide does the same whenever a module changes.

Build a backend that shows what it receives

Your real app might be Node or Python, often inside a container. To see exactly which headers arrive, use a second Apache virtual host on loopback instead. It needs no extra packages, and its log format records the request headers.

echo 'Listen 127.0.0.1:8080' | sudo tee -a /etc/apache2/ports.conf
sudo mkdir -p /srv/backend/v2
echo 'backend root' | sudo tee /srv/backend/index.html
echo 'backend v2' | sudo tee /srv/backend/v2/index.html

Ubuntu keeps its Listen lines in /etc/apache2/ports.conf. A Listen line with an address and a port binds that one interface only. So port 8080 is never reachable from the internet, whatever your firewall says. Run the tee -a line once. A second identical Listen line makes apache2ctl configtest fail with Cannot define multiple Listeners on the same IP:port.

Create /etc/apache2/sites-available/backend.conf:

<VirtualHost 127.0.0.1:8080>
    ServerName backend.internal
    DocumentRoot /srv/backend
    <Directory /srv/backend>
        Require all granted
    </Directory>
    LogFormat "%h host=\"%{Host}i\" xff=\"%{X-Forwarded-For}i\" xfh=\"%{X-Forwarded-Host}i\" xfp=\"%{X-Forwarded-Proto}i\" \"%r\" %>s" proxylab
    CustomLog ${APACHE_LOG_DIR}/backend-headers.log proxylab
</VirtualHost>

The <Directory> block is required. Ubuntu's apache2.conf denies access to / and then allows only a few directory trees. Its /srv block ships commented out, and the comment above it says content under /srv must be allowed there or in the virtual host. Without this block, the backend refuses every request.

%{Host}i logs a request header as it arrived at this virtual host. A LogFormat line with a nickname only defines the format. The CustomLog line is what applies it. On Ubuntu, /etc/apache2/envvars sets APACHE_LOG_DIR to /var/log/apache2.

Enable the backend. Also turn off the default site, so port 80 holds only the proxy you write next:

sudo a2ensite backend
sudo a2dissite 000-default
sudo apache2ctl configtest
sudo systemctl reload apache2
curl -s http://127.0.0.1:8080/
sudo tail -n 1 /var/log/apache2/backend-headers.log

apache2ctl configtest parses every config file. It should report that the syntax is OK. If it reports anything else, fix that before you reload. No module changed, so a reload is enough. It rereads every config file, ports.conf included. curl should print backend root. In the log line, host is 127.0.0.1:8080, which is what curl sent. The three X-Forwarded fields hold no address yet, because no proxy was involved.

Write the reverse proxy virtual host

Create /etc/apache2/sites-available/app.conf, and replace app.example.com with your own domain:

<VirtualHost *:80>
    ServerName app.example.com

    ProxyRequests Off
    ProxyPreserveHost Off
    RequestHeader set X-Forwarded-Proto "http"

    ProxyPass        /api/ http://127.0.0.1:8080/v2/
    ProxyPassReverse /api/ http://127.0.0.1:8080/v2/
    ProxyPass        /     http://127.0.0.1:8080/
    ProxyPassReverse /     http://127.0.0.1:8080/

    ErrorLog ${APACHE_LOG_DIR}/app-error.log
    CustomLog ${APACHE_LOG_DIR}/app-access.log combined
</VirtualHost>

What each line does:

  • ProxyRequests Off is already the default. It is written out so nobody turns it on by accident. On makes Apache a forward proxy for any site on the internet, and the docs warn that open proxies are dangerous. Setting it Off does not disable ProxyPass.
  • ProxyPreserveHost Off is also the default. The next section shows what it changes.
  • ProxyPass maps a URL prefix on this site to a URL on the backend. Here /api/ maps to /v2/ on the backend, and everything else maps to /.
  • ProxyPassReverse rewrites the Location, Content-Location and URI headers on redirect responses. Without it, a backend redirect sends the browser to 127.0.0.1:8080, which the browser cannot reach. It does not rewrite links inside HTML. That needs mod_proxy_html.

Enable the site and test it from the server:

sudo a2ensite app
sudo apache2ctl configtest
sudo systemctl reload apache2
curl -s -H 'Host: app.example.com' http://127.0.0.1/
curl -s -H 'Host: app.example.com' http://127.0.0.1/api/

The first curl should print backend root and the second backend v2. A reload is enough here, because no module changed. Once your DNS record points at the server, curl http://app.example.com/ from your laptop should give the same result.

Open only the public ports. The backend needs no firewall rule, because it listens on loopback. UFW firewall basics for a VPS covers the default policy and how to keep SSH open before you enable the firewall.

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ss -ltn

ss -ltn should list 127.0.0.1:8080, not 0.0.0.0:8080 or *:8080.

ProxyPreserveHost Off vs On: what the backend receives

Look at the two requests you just sent through the proxy:

sudo tail -n 2 /var/log/apache2/backend-headers.log

With ProxyPreserveHost Off, read each field like this:

  • The first field, %h, is 127.0.0.1. The backend sees a connection from the proxy, not from the visitor.
  • host is 127.0.0.1:8080. In the 2.4.x source (proxy_util.c), when ProxyPreserveHost is off, the proxy builds Host from the hostname in the ProxyPass URL. It adds the port only when the port is not 80.
  • xfh (X-Forwarded-Host) is app.example.com. The name the visitor typed is not lost. It moves to this header.
  • xff (X-Forwarded-For) is the visitor's address. Here that is 127.0.0.1, because curl ran on the server itself. From your laptop it would be your public IP.
  • xfp (X-Forwarded-Proto) is http, from the RequestHeader line.

Now change ProxyPreserveHost Off to ProxyPreserveHost On in app.conf, then run:

sudo apache2ctl configtest
sudo systemctl reload apache2
curl -s -H 'Host: app.example.com' http://127.0.0.1/
sudo tail -n 1 /var/log/apache2/backend-headers.log

host now reads app.example.com. The docs describe this exactly: with the option on, Apache passes the Host line from the incoming request instead of the hostname in the ProxyPass line.

Which one should you use? The Apache docs say the option should normally be off. They add that it is mostly useful when the backend must evaluate the original name, as in proxied name-based virtual hosting. That second sentence is the real test, so ask whether your app uses Host. An app needs On when it picks a site by name or builds absolute links from the request. The same goes for an app that rejects hostnames it does not know. Some apps can read X-Forwarded-Host instead, which keeps Off workable. An app that ignores Host works either way. A quick symptom check: if pages served through the proxy contain links to 127.0.0.1:8080, the app is building them from Host, and On is the usual fix.

The lab backend answers in both modes because it is the only virtual host on 127.0.0.1:8080. A backend with several name-based virtual hosts on one port chooses among them by Host. With Off, that backend would always get the address from your ProxyPass line.

When is RequestHeader set Host the right tool?

Sometimes the backend needs a third name. It is neither the public name nor the address in ProxyPass. A typical case is a backend with several virtual hosts, where the one you want is called backend.internal and the public site is app.example.com.

The simplest fix uses no extra directive. Put the name in the ProxyPass URL and make it resolve on the proxy:

echo '127.0.0.1 backend.internal' | sudo tee -a /etc/hosts
ProxyPreserveHost Off
ProxyPass        / http://backend.internal:8080/
ProxyPassReverse / http://backend.internal:8080/

With ProxyPreserveHost Off, the backend receives the hostname from the ProxyPass line. So it sees backend.internal, with :8080 added because 8080 is not the default port. This is documented behaviour, and the target URL now says exactly what the backend will see.

RequestHeader set Host is the right tool when you cannot change name resolution, or when the target must stay a bare IP address. It only works together with ProxyPreserveHost On:

ProxyPreserveHost On
RequestHeader set Host "backend.internal"
ProxyPass        / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/

The reason is the order of events. The mod_headers docs say RequestHeader runs in the fixup phase, just before the request reaches its handler, and the proxy is that handler. In the 2.4.x source, the proxy with ProxyPreserveHost On forwards whatever Host the request holds at that point, which is your value. With ProxyPreserveHost Off, the proxy overwrites Host with the ProxyPass hostname, so your RequestHeader line is silently replaced. The mod_proxy docs do not describe this interaction, so confirm it on your own version. Send a request and check that the host field in the backend log reads backend.internal.

Why does the backend see 127.0.0.1 instead of the visitor's IP?

Every connection to the backend comes from the proxy. So the backend's own idea of the client address is always 127.0.0.1. To keep the real address, mod_proxy_http adds three headers: X-Forwarded-For (the client IP), X-Forwarded-Host (the name the client asked for) and X-Forwarded-Server (the proxy's own hostname). ProxyAddHeaders controls them, and it defaults to On. Your app has to read the client address from X-Forwarded-For. For an Apache backend, the docs suggest %{X-Forwarded-For}i in the log format, which is what the lab does.

A client can send its own X-Forwarded-For. Try it:

curl -s -H 'Host: app.example.com' -H 'X-Forwarded-For: 203.0.113.50' http://127.0.0.1/
sudo tail -n 1 /var/log/apache2/backend-headers.log

The docs say these headers hold comma-separated values when the incoming request already had them. In the 2.4.x source, the proxy appends the address it saw to the existing value. So xff now holds 203.0.113.50 followed by 127.0.0.1. Only the last entry was written by your proxy. Everything before it came from the client and may be fake. With one proxy in front, tell your app to trust only the last address.

How do you set X-Forwarded-Proto behind TLS?

Apache does not add X-Forwarded-Proto for you. The mod_proxy docs list only the three headers above. Behind the proxy, your app receives plain HTTP from 127.0.0.1. If it decides the scheme from that connection, it believes every request is http, even when the visitor used HTTPS (transport layer security on top of HTTP). It then builds http:// links, and an app that forces HTTPS cannot tell that the visitor is already on it.

Get a certificate first by following Install Certbot for Apache on Ubuntu 24.04. Then open the <VirtualHost *:443> block for this site and add one line next to its ProxyPass lines:

RequestHeader set X-Forwarded-Proto "https"

Keep RequestHeader set X-Forwarded-Proto "http" in the port 80 block. Then reload, and confirm the port 443 virtual host is loaded:

sudo apache2ctl configtest
sudo systemctl reload apache2
sudo apache2ctl -S

Use set, not add. The mod_headers docs say set replaces any previous header with that name. A client therefore cannot claim HTTPS over a plain connection. Test that on port 80:

curl -s -H 'Host: app.example.com' -H 'X-Forwarded-Proto: https' http://127.0.0.1/
sudo tail -n 1 /var/log/apache2/backend-headers.log

xfp should read http, not https, because your line replaced the header the client sent.

In what order do ProxyPass rules match?

The Apache docs say top-level ProxyPass rules are checked in the order you wrote them, and the first match wins. So put the longest, most specific paths first and the catch-all / last. The /api/ pair above sits before / for that reason.

Swap the two pairs in app.conf, reload, and request /api/ again. The / rule now matches first, so the backend receives /api/ instead of /v2/. There is no /srv/backend/api, so the backend log shows GET /api/ with status 404. Put the order back afterwards.

Exclusions follow the same rule. ProxyPass /path ! tells Apache to serve that path itself, and the docs say exclusions must come before the general ProxyPass lines:

Alias /static/ /srv/static/
<Directory /srv/static>
    Require all granted
</Directory>

ProxyPass        /static/ !
ProxyPass        /api/ http://127.0.0.1:8080/v2/
ProxyPassReverse /api/ http://127.0.0.1:8080/v2/
ProxyPass        /     http://127.0.0.1:8080/
ProxyPassReverse /     http://127.0.0.1:8080/

<Location> blocks work the other way round. The docs say the last matching Location wins, so a more specific Location goes after a less specific one. Pick one style per virtual host. Mixing them makes the order hard to reason about.

Why do trailing slashes in ProxyPass matter?

The docs give one rule. If the first argument ends in /, the second must too, and the other way round. Otherwise the request to the backend may miss slashes it needs. ProxyPass /api/ http://127.0.0.1:8080/v2/ follows the rule. ProxyPass /api http://127.0.0.1:8080/v2/ breaks it.

A related trap: the prefix /api/ does not match a request for /api with no slash. That request falls through to the / rule and reaches the backend as /api. Whenever you edit a ProxyPass line, request a few paths and read the quoted request field (%r) in the backend log. It shows the exact path that arrived, which settles any doubt faster than reading the config again.

WebSocket on the packaged Apache

Since Apache 2.4.47, mod_proxy_http handles the WebSocket upgrade itself, through the upgrade parameter on ProxyPass. Both Ubuntu releases above are newer than that. So you do not need a2enmod proxy_wstunnel, and you do not need a ws:// URL. The 2.4 docs mark mod_proxy_wstunnel as deprecated. From 2.4.48 it hands WebSocket requests over to mod_proxy_http by default anyway.

Add the WebSocket path before the catch-all. Port 3000 here stands for your real app:

ProxyPass        /ws/ http://127.0.0.1:3000/ws/ upgrade=websocket
ProxyPassReverse /ws/ http://127.0.0.1:3000/ws/
ProxyPass        /    http://127.0.0.1:8080/
ProxyPassReverse /    http://127.0.0.1:8080/

The lab backend cannot speak WebSocket, so test against your app. This command sends a WebSocket handshake through the proxy:

curl -i -N --max-time 5 \
  -H 'Host: app.example.com' \
  -H 'Connection: Upgrade' \
  -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  http://127.0.0.1/ws/

A WebSocket server that accepts the handshake answers with status 101 Switching Protocols, as RFC 6455 defines. curl then holds the connection open until --max-time ends. Any other status means the upgrade did not happen. Check that the /ws/ rule comes before /, that upgrade=websocket is on the line, and what your app logged. The docs also offer upgrade=ANY and upgrade=NONE, but the mod_proxy_wstunnel page says they are not recommended in production for security reasons.

Common failures and how to check them

The proxy returns 503. Apache could not connect to the backend. Run curl -s http://127.0.0.1:8080/ on the server. If that fails too, the backend is down, or it listens on a different address than the one in ProxyPass. Then read /var/log/apache2/app-error.log for the proxy's side of the story.

configtest rejects ProxyPass or RequestHeader. The module that provides the directive is not enabled. Run sudo a2enmod proxy proxy_http headers, then restart Apache.

A redirect sends the browser to 127.0.0.1:8080. ProxyPassReverse is missing, or its URL does not match the ProxyPass target character for character. Check with curl -sI -H 'Host: app.example.com' http://127.0.0.1/some/path and read the Location header.

Links inside pages point at the backend. ProxyPassReverse never touches HTML. Configure the app's public base URL, or turn on ProxyPreserveHost so the app sees the public name. mod_proxy_html can rewrite HTML as a last resort.

The app thinks HTTPS visitors use HTTP. X-Forwarded-Proto is missing from the port 443 virtual host, or the app is not configured to trust it.

If you later run nginx next to Apache or replace it, how an nginx reverse proxy config works maps the same ideas onto proxy_pass and proxy_set_header.

FAQ

Do I need ProxyRequests Off for an Apache reverse proxy?

No. ProxyRequests already defaults to Off, and Off does not disable ProxyPass. Writing the line out is still good practice, because it stops anyone from turning on forward proxying by accident. With ProxyRequests On, Apache becomes a proxy for any site on the internet, and the Apache docs warn that open proxies are dangerous to your network and to the internet at large.

Should ProxyPreserveHost be On or Off?

Leave it Off unless the backend uses the Host header. With Off, the backend sees the hostname from your ProxyPass URL, for example 127.0.0.1:8080, and the visitor's hostname arrives in X-Forwarded-Host. Turn it On when the app chooses a site by name or builds absolute links from the request. Then the backend receives the hostname the visitor typed.

Why does my backend log 127.0.0.1 instead of the visitor's IP address?

The backend's connection comes from the proxy on the same server, so the connection address is always 127.0.0.1. Apache's mod_proxy_http adds the visitor's address in X-Forwarded-For, so log %{X-Forwarded-For}i or configure your app to read that header. If a client sends its own X-Forwarded-For, Apache appends the real address to the end. Trust only the last entry when one proxy sits in front.

Do I need mod_proxy_wstunnel for WebSockets on Ubuntu?

No. Since Apache 2.4.47, mod_proxy_http handles the WebSocket upgrade when you add upgrade=websocket to the ProxyPass line. The apache2 packages on Ubuntu 24.04 and 26.04 are based on 2.4.58 and 2.4.66. mod_proxy_wstunnel is marked deprecated in the 2.4 docs. Keep the backend URL as http://, and put the WebSocket path before the catch-all / rule.