Ubuntu 24.04: Apache vs Nginx confusion — fix port binding and proxy loops cleanly

Was this helpful?

You install “a web server” on Ubuntu 24.04 and suddenly you have two. One binds to port 80, the other swears it did first, and your browser gets a redirect loop that feels like a treadmill with paperwork.

Case #34 is the classic: Apache and Nginx both enabled, both trying to own 80/443, and a reverse proxy config that accidentally proxies back to itself. The good news: this is fixable without drama. The better news: you can make it boring and repeatable, the way production systems like it.

What it looks like in the wild (symptoms you’ll actually see)

This problem doesn’t announce itself politely. It shows up as:

  • Nginx won’t start: bind() to 0.0.0.0:80 failed (98: Address already in use)
  • Apache won’t start: (98)Address already in use: AH00072: make_sock: could not bind to address
  • Browser loops: endless ERR_TOO_MANY_REDIRECTS
  • Nginx 502/504: backend not reachable, or reachable but wrong protocol
  • You curl localhost and get the “wrong” site: default page from the other server
  • Works on localhost, fails externally: because binding differs between 127.0.0.1 and 0.0.0.0 or IPv6

The root causes nearly always fall into one of these buckets:

  • Port ownership conflict: both Apache and Nginx are trying to listen on 80 and/or 443.
  • Accidental proxy recursion: Nginx proxies to itself, or Apache proxies to itself, often via localhost:80.
  • HTTP↔HTTPS confusion: backend thinks it’s HTTP, front-end thinks it’s HTTPS, redirects bounce forever.
  • Name-based vhost mismatch: Host header not preserved or default server block catches traffic.

One short joke, because we’re about to get serious: When two daemons fight over port 80, Linux doesn’t pick a winner; it just hands you a log file and watches.

Fast diagnosis playbook (first/second/third)

When you’re on-call, you don’t need a lecture. You need a sequence that reveals the bottleneck quickly.
This is the sequence I run when Ubuntu 24.04 is hosting a confused web stack.

First: who owns 80/443 right now?

  • Check listening sockets for :80 and :443 (IPv4 and IPv6).
  • Decide which process should own those ports (front-end) and force the other to bind elsewhere or not at all.

Second: is there a proxy loop?

  • Look for proxy_pass http://localhost (Nginx) or ProxyPass http://localhost (Apache) pointing back to a port the proxy itself owns.
  • Follow redirects with curl. If you see a repeating Location pattern, you’re in a loop.

Third: are headers and scheme being communicated correctly?

  • Confirm X-Forwarded-Proto and Host reach the backend.
  • Confirm the backend trusts the proxy and doesn’t “upgrade” to HTTPS incorrectly.

Fourth (only if needed): default vhost and SNI behavior

  • Check which vhost is the default and whether your server_name/ServerName matches.
  • On TLS, confirm SNI is selecting the right certificate and server block.

Interesting facts and context (the stuff that explains why this happens)

A few concrete facts help you reason about the mess instead of poking at it blindly.
Here are nine that matter in practice:

  1. Apache predates Nginx by years: Apache HTTP Server started in the mid-1990s; Nginx arrived in the early 2000s to handle high concurrency efficiently.
  2. The “C10k problem” influenced Nginx’s design: event-driven architectures became a big deal when handling ten thousand concurrent connections stopped being theoretical.
  3. Both servers can do both roles now: Apache can reverse proxy and terminate TLS; Nginx can serve static and proxy app servers. The tool choice is mostly operational preference.
  4. Linux allows only one listener per IP:port tuple: unless you use advanced tricks like SO_REUSEPORT (and most web stacks should not).
  5. IPv6 can be the hidden owner: you might “free” IPv4 :80 but Apache still binds [::]:80, and Nginx fails anyway.
  6. systemd changes failure visibility: you don’t “start a daemon”; you start a unit, and unit dependencies can restart or keep things half-alive.
  7. Default sites are designed to catch you: Debian/Ubuntu ship default-enabled sites for Apache and Nginx. They’re helpful until they aren’t.
  8. Redirect loops are often a scheme mismatch: the backend thinks the request is HTTP and redirects to HTTPS, but the proxy already did HTTPS and sent HTTP to backend.
  9. “localhost” is a footgun in proxies: on the same machine, “localhost:80” often points back to the proxy itself, not the intended backend.

One quote to keep you honest: “Hope is not a strategy.” — General Gordon R. Sullivan.

Pick a sane architecture (and stick to it)

The main decision: which daemon is the front door for port 80/443?
If you keep both, one is the edge proxy and the other is a backend service bound to a loopback address or alternate port. Anything else is a fight club.

Recommended patterns

  • Pattern A (common): Nginx on 80/443 → Apache on 127.0.0.1:8080
    Use Nginx for TLS termination, HTTP/2/3 (if desired), buffering, and static caching. Apache handles legacy apps, .htaccess, or apps designed around Apache modules.
  • Pattern B: Apache on 80/443 → app backends (PHP-FPM, upstream services)
    If you don’t need Nginx, don’t run it. Apache can do reverse proxying and TLS just fine.
  • Pattern C: Nginx only
    Cleanest for many deployments: Nginx + PHP-FPM (or upstream app) and no Apache at all.

What to avoid

  • Both Apache and Nginx listening on 80/443. If it “works,” it’s only because one failed to start or isn’t actually bound.
  • Proxying to localhost:80 from the edge proxy. That’s how you build an infinite recursion machine.
  • Mixing TLS termination responsibilities. Either TLS ends at the proxy, or TLS ends at the backend. Don’t improvise mid-flight.

Practical tasks: commands, expected output, and decisions (12+)

These are real tasks you can run on Ubuntu 24.04 to diagnose and fix the issue.
Each task includes: command, example output, what it means, and the decision you make.

Task 1: Identify who is listening on ports 80 and 443 (IPv4/IPv6)

cr0x@server:~$ sudo ss -ltnp '( sport = :80 or sport = :443 )'
State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
LISTEN 0      511          0.0.0.0:80         0.0.0.0:*     users:(("apache2",pid=1421,fd=4))
LISTEN 0      511             [::]:80            [::]:*     users:(("apache2",pid=1421,fd=5))
LISTEN 0      511          0.0.0.0:443        0.0.0.0:*     users:(("nginx",pid=1550,fd=6))

Meaning: Apache owns port 80 (both v4 and v6). Nginx owns 443. That’s already inconsistent.

Decision: Pick one front-end for both ports; usually Nginx on both 80/443, and move Apache to 127.0.0.1:8080.

Task 2: Confirm service state and recent failures

cr0x@server:~$ systemctl status nginx --no-pager
● nginx.service - A high performance web server and a reverse proxy server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
     Active: active (running) since Mon 2025-12-30 09:12:01 UTC; 5min ago
       Docs: man:nginx(8)
   Main PID: 1550 (nginx)
      Tasks: 3 (limit: 18906)
     Memory: 8.4M
        CPU: 120ms
cr0x@server:~$ systemctl status apache2 --no-pager
● apache2.service - The Apache HTTP Server
     Loaded: loaded (/usr/lib/systemd/system/apache2.service; enabled; preset: enabled)
     Active: active (running) since Mon 2025-12-30 09:10:44 UTC; 6min ago
       Docs: https://httpd.apache.org/docs/2.4/
   Main PID: 1421 (apache2)

Meaning: Both are enabled and running. That’s okay only if they are not fighting for the same ports.

Decision: If you want a proxy architecture, keep both but rebind Apache. If you don’t, stop and disable one.

Task 3: Look for “address already in use” in logs quickly

cr0x@server:~$ sudo journalctl -u nginx -u apache2 --since "1 hour ago" | tail -n 40
Dec 30 09:08:11 server nginx[1490]: nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)
Dec 30 09:08:11 server nginx[1490]: nginx: [emerg] still could not bind()
Dec 30 09:08:12 server systemd[1]: nginx.service: Failed with result 'exit-code'.
Dec 30 09:11:59 server systemd[1]: Started nginx.service - A high performance web server and a reverse proxy server.

Meaning: Nginx tried to bind 80 and lost at least once. Something changed later (maybe Apache stopped briefly, or Nginx config changed).

Decision: Fix port ownership so restarts are deterministic.

Task 4: Check what Apache is configured to listen on

cr0x@server:~$ sudo grep -R "^[[:space:]]*Listen" -n /etc/apache2/ports.conf /etc/apache2/sites-enabled/*.conf
/etc/apache2/ports.conf:5:Listen 80
/etc/apache2/ports.conf:8:Listen 443

Meaning: Apache is explicitly listening on both 80 and 443.

Decision: If Nginx is the edge, remove these and move Apache to loopback:8080 (and possibly 127.0.0.1:8443 if you insist on TLS internally).

Task 5: Check what Nginx is configured to listen on

cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -R --line-number -E "listen\s+(80|443)" /etc/nginx/sites-enabled /etc/nginx/conf.d
/etc/nginx/sites-enabled/default:22:	listen 80 default_server;
/etc/nginx/sites-enabled/default:23:	listen [::]:80 default_server;
/etc/nginx/sites-enabled/default:30:	listen 443 ssl default_server;
/etc/nginx/sites-enabled/default:31:	listen [::]:443 ssl default_server;

Meaning: Nginx is also configured for both ports as default server. Classic collision.

Decision: Keep Nginx on 80/443; move Apache off those ports.

Task 6: Detect an accidental proxy loop in Nginx

cr0x@server:~$ sudo grep -R --line-number -E "proxy_pass\s+http://(localhost|127\.0\.0\.1)(:80)?/?;" /etc/nginx/sites-enabled
/etc/nginx/sites-enabled/app.conf:41:    proxy_pass http://localhost;

Meaning: If Nginx listens on 80 and proxies to http://localhost (which defaults to port 80), it’s proxying to itself. That’s a loop generator.

Decision: Proxy to a backend port like 127.0.0.1:8080, or to a Unix socket, or to an upstream service.

Task 7: Detect an accidental proxy loop in Apache

cr0x@server:~$ sudo grep -R --line-number -E "ProxyPass\s+/" /etc/apache2/sites-enabled
/etc/apache2/sites-enabled/000-default.conf:18:ProxyPass / http://127.0.0.1:80/
/etc/apache2/sites-enabled/000-default.conf:19:ProxyPassReverse / http://127.0.0.1:80/

Meaning: Apache is proxying “/” to 127.0.0.1:80. If Apache itself owns 80, that’s recursion.

Decision: Either remove Apache proxying or ensure the target is a different port/service.

Task 8: Follow redirects and see the loop with curl

cr0x@server:~$ curl -I -L --max-redirs 10 http://example.internal/
HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://example.internal/

HTTP/2 301
server: nginx
location: http://example.internal/

curl: (47) Maximum (10) redirects followed

Meaning: HTTP redirects to HTTPS, then HTTPS redirects back to HTTP. That’s almost always an “X-Forwarded-Proto” or backend scheme detection issue.

Decision: Ensure one component is responsible for redirects, and the backend understands the original scheme.

Task 9: Confirm the Host header and forwarding headers from the edge

cr0x@server:~$ sudo nginx -T 2>/dev/null | sed -n '1,120p'
# configuration file /etc/nginx/nginx.conf:
user www-data;
worker_processes auto;
...
cr0x@server:~$ sudo grep -R --line-number -E "proxy_set_header\s+(Host|X-Forwarded-Proto|X-Forwarded-For)" /etc/nginx/sites-enabled
/etc/nginx/sites-enabled/app.conf:35:    proxy_set_header Host $host;
/etc/nginx/sites-enabled/app.conf:36:    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
/etc/nginx/sites-enabled/app.conf:37:    proxy_set_header X-Forwarded-Proto $scheme;

Meaning: These headers are present. Good. If missing, backends often mis-detect scheme/host and redirect.

Decision: If redirect loops persist, confirm the backend trusts these headers and isn’t overriding them.

Task 10: Verify Apache vhost bindings after changes (sanity check)

cr0x@server:~$ sudo apachectl -S
VirtualHost configuration:
*:8080                 is a NameVirtualHost
         default server app.internal (/etc/apache2/sites-enabled/001-app.conf:1)
         port 8080 namevhost app.internal (/etc/apache2/sites-enabled/001-app.conf:1)
ServerRoot: "/etc/apache2"
Main DocumentRoot: "/var/www/html"
Main ErrorLog: "/var/log/apache2/error.log"

Meaning: Apache is now bound to 8080 for vhosts, not 80/443. That’s what you want when Nginx is in front.

Decision: Proceed to restart services and validate end-to-end.

Task 11: Validate Nginx config and reload without guessing

cr0x@server:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Meaning: Syntax is OK. This doesn’t prove your proxy target is correct, but it prevents “reload broke prod” mistakes.

Decision: Reload Nginx only after a successful test.

Task 12: Confirm backend reachability directly (bypass proxy)

cr0x@server:~$ curl -sS -D- http://127.0.0.1:8080/ -o /dev/null | head
HTTP/1.1 200 OK
Date: Tue, 30 Dec 2025 09:20:17 GMT
Server: Apache/2.4.58 (Ubuntu)
Content-Type: text/html; charset=UTF-8

Meaning: Apache backend is reachable on 8080 and returns 200. Good baseline.

Decision: If this fails, fix the backend first. Don’t debug the proxy while the backend is down.

Task 13: Confirm edge behavior (Nginx on 80/443)

cr0x@server:~$ curl -sS -D- http://127.0.0.1/ -o /dev/null | head
HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://app.internal/
cr0x@server:~$ curl -k -sS -D- https://127.0.0.1/ -o /dev/null | head
HTTP/2 200
server: nginx
content-type: text/html; charset=UTF-8

Meaning: Nginx is the edge. HTTP redirects to HTTPS; HTTPS returns 200.

Decision: If HTTPS returns 301 back to HTTP, fix forwarded proto / backend redirect logic.

Task 14: Verify no other process is squatting on ports

cr0x@server:~$ sudo lsof -nP -iTCP:80 -sTCP:LISTEN
COMMAND  PID     USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
nginx   1550     root    6u  IPv4  29826      0t0  TCP *:80 (LISTEN)
nginx   1550     root    7u  IPv6  29827      0t0  TCP *:80 (LISTEN)

Meaning: Only Nginx listens on 80. That’s the “boring” state you want.

Decision: If you see Apache or something else here, stop and re-check configs and enabled units.

Fix port binding cleanly (Apache or Nginx, choose your owner)

The fix is not “restart until it works.” The fix is: one service owns the public ports, the other doesn’t.
Below are two clean solutions. Choose one. Commit to it.

Solution 1 (recommended for mixed stacks): Nginx owns 80/443, Apache moves to 127.0.0.1:8080

This is the most common production pattern when you need Apache for application reasons (legacy rewrites, modules, .htaccess)
but prefer Nginx for edge proxy behavior.

Step A: Move Apache off 80/443

Edit /etc/apache2/ports.conf to bind Apache to loopback only:

cr0x@server:~$ sudo sed -n '1,120p' /etc/apache2/ports.conf
# If you just change the port or add more ports here, you will likely also
# have to change the VirtualHost statement in
# /etc/apache2/sites-enabled/000-default.conf

Listen 80
Listen 443

Replace with:

cr0x@server:~$ sudo bash -lc 'cat > /etc/apache2/ports.conf <<EOF
Listen 127.0.0.1:8080

<IfModule ssl_module>
    Listen 127.0.0.1:8443
</IfModule>

<IfModule mod_gnutls.c>
    Listen 127.0.0.1:8443
</IfModule>
EOF'

Why loopback? Because it prevents Apache from being reachable directly from the network.
That eliminates a whole category of “bypass the proxy” bugs and security surprises.

Step B: Update Apache vhosts to match the new port

Your enabled sites likely contain <VirtualHost *:80>. Change them to 127.0.0.1:8080 or *:8080 depending on preference.
I like explicit loopback to prevent mistakes.

cr0x@server:~$ sudo grep -R --line-number "<VirtualHost" /etc/apache2/sites-enabled
/etc/apache2/sites-enabled/000-default.conf:1:<VirtualHost *:80>
cr0x@server:~$ sudo sed -i 's/<VirtualHost \*:80>/<VirtualHost 127.0.0.1:8080>/' /etc/apache2/sites-enabled/000-default.conf

If you were using Apache TLS vhosts (*:443), decide whether you still need them.
In a typical “Nginx terminates TLS” design, you disable Apache TLS vhosts entirely and keep Apache HTTP-only internally.

Step C: Disable Apache SSL and port 443 listeners if Nginx terminates TLS

cr0x@server:~$ sudo a2dismod ssl
Module ssl disabled.
To activate the new configuration, you need to run:
  systemctl restart apache2

Meaning: Apache won’t load mod_ssl. If you still need internal TLS, keep it enabled and use 8443 loopback, but be consistent.

Decision: Most teams: terminate TLS at Nginx, run plain HTTP to Apache on 127.0.0.1:8080.

Step D: Restart Apache, then ensure it no longer binds 80/443

cr0x@server:~$ sudo systemctl restart apache2
cr0x@server:~$ sudo ss -ltnp '( sport = :80 or sport = :443 or sport = :8080 )'
State  Recv-Q Send-Q Local Address:Port   Peer Address:Port Process
LISTEN 0      511    127.0.0.1:8080        0.0.0.0:*     users:(("apache2",pid=2012,fd=3))
LISTEN 0      511      0.0.0.0:80          0.0.0.0:*     users:(("nginx",pid=1550,fd=6))
LISTEN 0      511      0.0.0.0:443         0.0.0.0:*     users:(("nginx",pid=1550,fd=7))

Meaning: Perfect separation. Nginx is the edge. Apache is internal.

Decision: Now configure Nginx upstream to 127.0.0.1:8080.

Step E: Configure Nginx reverse proxy properly

In your Nginx site, your proxy block should look like this (the important part is the backend port and headers):

cr0x@server:~$ sudo bash -lc 'cat > /etc/nginx/sites-available/app.conf <<EOF
server {
    listen 80;
    listen [::]:80;
    server_name app.internal;

    return 301 https://\$host\$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name app.internal;

    ssl_certificate     /etc/ssl/certs/ssl-cert-snakeoil.pem;
    ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host \$host;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }
}
EOF'
cr0x@server:~$ sudo ln -sf /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/app.conf
cr0x@server:~$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
cr0x@server:~$ sudo systemctl reload nginx

Solution 2: Apache owns 80/443, remove Nginx from the equation

This is the right move when Nginx was installed “because someone said it was faster”
and then nobody actually used it intentionally.

Step A: Stop and disable Nginx

cr0x@server:~$ sudo systemctl stop nginx
cr0x@server:~$ sudo systemctl disable --now nginx
Removed "/etc/systemd/system/multi-user.target.wants/nginx.service".

Meaning: Nginx won’t start on reboot or by default targets.

Decision: If you never needed Nginx, keep it off. Less moving parts, fewer 3 a.m. surprises.

Step B: Confirm Apache binds to the public ports

cr0x@server:~$ sudo ss -ltnp '( sport = :80 or sport = :443 )'
State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
LISTEN 0      511          0.0.0.0:80         0.0.0.0:*     users:(("apache2",pid=1421,fd=4))
LISTEN 0      511          0.0.0.0:443        0.0.0.0:*     users:(("apache2",pid=1421,fd=5))

Meaning: Apache is the edge now.

Decision: Remove any reverse proxy configuration that was only needed because Nginx existed, and validate redirects/certs in Apache.

Kill proxy loops and redirect loops (for good)

Port conflicts are loud. Proxy loops are sneaky.
The system can be “up” while users get bounced in circles or traffic melts your CPU with pointless recursion.

Proxy loops: the mechanics

A proxy loop happens when a proxy forwards a request to an address that routes back to the proxy itself.
On a single host, that’s usually localhost or 127.0.0.1 on the same port the proxy listens on.

Example bad Nginx configuration:
listen 80; and proxy_pass http://localhost;.
That says: “Receive request on 80, send request to 80.” The only missing part is an apology letter.

Redirect loops: the usual culprit

Redirect loops are most often “scheme confusion.” Your backend sees HTTP (because the proxy talks HTTP to it),
but users are on HTTPS (because the proxy terminates TLS). If the backend enforces HTTPS by redirecting,
it sends a redirect to HTTPS. The proxy receives that request and again sends HTTP to the backend. Repeat until the browser gives up.

Fix scheme confusion: do three things, not one

  • Send X-Forwarded-Proto from the proxy. Nginx: proxy_set_header X-Forwarded-Proto $scheme;
  • Make the backend trust the proxy headers. Many frameworks require explicit “trusted proxies” configuration.
  • Ensure redirects happen in one place. Usually the edge proxy, because it knows what the client used.

Test loops intentionally with curl

Don’t test by refreshing a browser and squinting. Use curl -I -L and cap redirects.
You want to see a small, sane chain: maybe one 301 from HTTP→HTTPS, then 200.

cr0x@server:~$ curl -I -L --max-redirs 5 http://app.internal/ | sed -n '1,20p'
HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://app.internal/

HTTP/2 200
server: nginx

Meaning: One redirect, then success. That’s clean.

Decision: If you see alternating http/https locations, fix forwarded proto and backend redirect logic.

Second short joke (and we’re done with jokes): A redirect loop is like a corporate meeting—everyone is “aligning” and nothing ships.

Three corporate mini-stories (because production has a memory)

1) Incident caused by a wrong assumption: “localhost means the backend”

A mid-sized company ran a single Ubuntu host for an internal tool. They added Nginx “just for TLS” in front of Apache.
An engineer copied a proxy snippet from an older wiki: proxy_pass http://localhost;. It looked harmless.
The assumption: localhost equals Apache.

But Apache still listened on port 80, and so did Nginx. After a restart sequence, Nginx ended up as the listener on 80.
Now localhost:80 meant Nginx itself. The loop wasn’t immediately obvious because health checks were naive:
they hit / and accepted a 301.

Under load, the loop manifested as CPU spikes and connection pileups. Nginx workers weren’t “slow”; they were busy talking to themselves.
Apache logs looked calm, because Apache wasn’t doing anything. The on-call saw 502s and started scaling up the VM,
which improved nothing except the cloud bill.

The fix took five minutes once someone ran ss -ltnp and followed redirects with curl.
The lesson wasn’t “don’t use localhost.” It was: never proxy to the same port you listen on, and always make backend ports explicit.

2) Optimization that backfired: “Let’s enforce HTTPS everywhere”

Another org ran Nginx at the edge with Apache behind it. They decided to standardize on HTTPS and added redirect rules in both layers.
Nginx redirected HTTP to HTTPS. Apache also redirected HTTP to HTTPS, “just in case.”
Nobody wrote down the intended source of truth.

It worked in staging where everything was plain HTTP. In production, TLS terminated at Nginx.
Nginx spoke HTTP to Apache. Apache saw an HTTP request and did its “helpful” redirect to HTTPS.
It didn’t know the original client scheme. It just knew what it saw.

The redirect URL it generated bounced the client back to Nginx, which then sent HTTP to Apache again.
Infinite loop. The monitoring showed a rise in 301 responses, which looked “healthy” if you only checked status codes.
Users were locked out of the app, and support tickets showed the classic “too many redirects.”

The rollback was easy: remove Apache-side HTTPS enforcement and rely on Nginx for redirects.
Then they added X-Forwarded-Proto and configured the application to trust it,
so any app-generated URLs were correct. The “optimization” wasn’t enforcing HTTPS; it was enforcing it twice.

3) Boring but correct practice that saved the day: bind the backend to loopback and document ports

A finance-adjacent team had a reputation for being slow-moving. They also had fewer incidents, annoyingly.
Their rule: edge services bind public ports; backends bind to 127.0.0.1 or a private VLAN only. Always.
They also kept a small /etc/services.d/README describing which daemon owned which ports.

During an OS upgrade, a package dependency pulled in Apache.
It auto-enabled a default site. On many systems that would have become an outage: new daemon, new ports, surprise listeners.
On theirs, Apache couldn’t bind to 80/443 because Nginx already owned them, and Apache was configured (by policy) to bind loopback only.

The net effect was a harmless warning in journald and zero customer impact.
The on-call investigated the next morning with coffee instead of adrenaline.
They disabled Apache and moved on.

The moral is painfully unsexy: boring port-binding policies prevent exciting incidents. Exciting incidents do not impress auditors.

Common mistakes: symptom → root cause → fix

1) Nginx fails to start: “Address already in use”

Symptom: nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)

Root cause: Apache (or another service) is already listening on 80/443 (possibly IPv6 only).

Fix: Decide the port owner; move the other daemon to 127.0.0.1:8080 or disable it. Verify with ss -ltnp.

2) Apache fails to start: AH00072 bind errors

Symptom: Apache logs show AH00072 and the service won’t start.

Root cause: Nginx is already listening on the ports Apache is configured for.

Fix: Same discipline: one edge. Rebind Apache in /etc/apache2/ports.conf and update vhosts, or disable Nginx.

3) Browser: ERR_TOO_MANY_REDIRECTS

Symptom: Browser reports redirect loop; curl shows alternating http/https locations.

Root cause: Duplicate HTTPS enforcement, or backend not honoring X-Forwarded-Proto, or wrong app base URL.

Fix: Make redirects happen at one layer (usually edge). Set forwarding headers. Configure backend/app to trust proxy and generate correct scheme URLs.

4) Nginx 502 Bad Gateway

Symptom: Nginx returns 502; error log mentions connect() failed or upstream prematurely closed connection.

Root cause: Backend is down, wrong port, wrong protocol (proxying HTTP to an HTTPS backend or vice versa), or binding only on IPv6/IPv4 mismatch.

Fix: Test backend directly with curl; verify listen sockets; correct proxy_pass target; ensure backend binds 127.0.0.1 if local.

5) “It works on localhost but not from outside”

Symptom: Local curl works; remote fails or hits a default site.

Root cause: Service binds only to loopback or only to IPv6; firewall rules; default vhost catches unmatched Host header.

Fix: Verify bindings with ss; verify Host routing with curl -H "Host: ..."; adjust server_name/ServerName and default_server settings.

6) Wrong site served (default page)

Symptom: You get “Apache2 Ubuntu Default Page” or Nginx welcome page.

Root cause: Default site enabled and winning precedence; server_name mismatch; SNI mismatch on TLS.

Fix: Disable defaults, ensure correct vhost order, set explicit server_name/ServerName, validate SNI with curl and vhost dumps.

Checklists / step-by-step plan

Checklist A: “I just want it fixed” (Nginx edge, Apache backend)

  1. Run ss -ltnp for 80/443 and write down owners.
  2. Move Apache to 127.0.0.1:8080 in /etc/apache2/ports.conf.
  3. Update Apache vhosts from *:80 to 127.0.0.1:8080.
  4. Disable Apache SSL vhosts unless you have a reason for internal TLS.
  5. Restart Apache, confirm it only listens on 8080.
  6. Configure Nginx to listen on 80/443 and proxy to http://127.0.0.1:8080.
  7. Ensure Nginx sets Host, X-Forwarded-For, X-Forwarded-Proto.
  8. Test backend directly (curl to 127.0.0.1:8080), then test edge (curl to 127.0.0.1:80/443).
  9. Follow redirects with curl and cap redirects. One redirect max for HTTP→HTTPS.
  10. Only then expose to real traffic.

Checklist B: “I want one web server” (Apache only)

  1. Stop and disable Nginx.
  2. Confirm Apache listens on 80/443 and serves the right vhost.
  3. Remove Nginx-era redirect logic from Apache if it’s duplicative.
  4. Validate TLS certs and vhost selection.
  5. Confirm there are no leftover firewall rules expecting Nginx behavior.

Checklist C: Regression tests after any change

  • Ports: verify listeners on 80/443/8080 with ss.
  • Config syntax: nginx -t, apachectl configtest.
  • Direct backend test: curl backend port directly.
  • Edge test: curl edge HTTP and HTTPS, follow redirects, confirm final status.
  • Host routing: curl with a Host header to ensure vhost match.
  • Logs: check Nginx error log and Apache error log for immediate regressions.
cr0x@server:~$ sudo apachectl configtest
Syntax OK

Meaning: Apache config parses. It doesn’t mean your vhosts are correct, but it reduces “reload roulette.”

FAQ (the questions you’ll ask at 2 a.m.)

1) Can Apache and Nginx run on the same server?

Yes. They just can’t both listen on the same IP:port. Choose an edge (80/443) and move the other to loopback or a different port.

2) Why does Nginx say port 80 is in use when I don’t see Apache on IPv4?

Because something might be listening on IPv6 ([::]:80). ss -ltnp will show both stacks.
Fix by changing the listener or disabling the other service.

3) Should I proxy to “localhost” or “127.0.0.1”?

Use 127.0.0.1:8080 (explicit port) or a Unix socket. “localhost” without a port invites ambiguity and loops.

4) Why do I get the default Apache/Nginx page instead of my app?

Your request is landing in a default vhost (wrong server_name/ServerName, wrong order, or unmatched Host header).
Disable default site configs and make hostnames explicit.

5) What causes HTTP↔HTTPS redirect loops behind a reverse proxy?

Usually scheme mismatch. The backend sees HTTP and forces HTTPS, but the proxy already did HTTPS and forwards HTTP internally.
Fix by setting X-Forwarded-Proto and making the backend trust it; enforce redirects at one layer.

6) If Nginx terminates TLS, should Apache still listen on 443?

Typically no. Keep Apache internal HTTP-only on 127.0.0.1:8080. Internal TLS is a valid choice, but it must be intentional and consistent.

7) How do I confirm the proxy is actually sending the Host header?

Check Nginx config for proxy_set_header Host $host;. Then verify backend behavior by curling through Nginx with different Host headers.

cr0x@server:~$ curl -sS -H "Host: app.internal" http://127.0.0.1/ -o /dev/null -D- | head
HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://app.internal/

8) What’s the cleanest way to ensure Apache is not reachable directly?

Bind Apache to 127.0.0.1 only. Don’t rely on firewall rules as your first line of defense for this; make the socket unreachable from the network.

9) Do systemd socket units change this story?

They can. Socket activation can hold ports open independently of the service process.
In the Apache/Nginx world on Ubuntu, you’ll usually see plain services, but always verify with ss and systemctl list-sockets.

cr0x@server:~$ systemctl list-sockets --no-pager | head
LISTEN               UNIT                        ACTIVATES
/dev/log             systemd-journald.socket     systemd-journald.service
...

10) I fixed ports, but I still get 502. Now what?

Confirm backend reachability and protocol. Curl the backend directly. Then check Nginx error logs for upstream connect errors.
502 is usually “can’t reach upstream” or “upstream spoke something unexpected.”

cr0x@server:~$ sudo tail -n 20 /var/log/nginx/error.log
2025/12/30 09:24:08 [error] 1552#1552: *12 connect() failed (111: Connection refused) while connecting to upstream, client: 10.0.2.15, server: app.internal, request: "GET / HTTP/2.0", upstream: "http://127.0.0.1:8080/", host: "app.internal"

Conclusion: next steps that keep you out of this mess

The fix for Apache vs Nginx confusion on Ubuntu 24.04 is not cleverness. It’s ownership.
Decide who owns 80/443. Bind the backend to loopback. Make proxy targets explicit. Then test with curl like you mean it.

Practical next steps:

  • Write down your intended architecture (Nginx edge → Apache 8080, or Apache-only, or Nginx-only).
  • Enforce port ownership in config, not tribal memory.
  • Add a tiny post-change smoke test: ss listeners + curl redirect chain + backend direct curl.
  • Disable default sites you don’t use. Defaults are friendly until they’re not.
  • Keep redirects in one layer, and be explicit about forwarded headers.

If you do those, case #34 stays solved. Permanently. The best kind of incident is the one you stop having.

← Previous
Proxmox Ceph “mon down/out of quorum”: restoring monitors and quorum without panic
Next →
ZFS vdev width planning: Why More Disks per VDEV Has a Cost

Leave a comment