Apache Reverse Proxy for Python Apps on FreeBSD

Why this Apache reverse proxy for Python apps setup fits production on FreeBSD
This tutorial shows you how to place Apache in front of a Python app on FreeBSD and send traffic to a local app server safely. The result is a production-ready Apache reverse proxy for Python apps that can run on a Hostperl VPS or a small dedicated server without exposing the app process to the internet. If you are planning a launch, a migration, or a clean split between web serving and application logic, this pattern keeps the public edge simple and the Python service isolated.
Apache fits this role well on FreeBSD. Service control is straightforward, pf can block the backend port, and you can keep the Python runtime bound to localhost. For teams comparing deployment options on Hostperl VPS hosting, this is a practical choice when you want stable HTTP handling, easy log review, and a path to TLS later without changing app code.
We will build a small Python WSGI app behind Apache, enable the required modules, add a restricted service account, and verify that Apache proxies requests correctly. The same pattern works well for Flask, FastAPI behind a WSGI bridge, Django, or a custom internal API that should not listen on a public port.
What you need before you start
- A FreeBSD server with root access.
- A working domain name, or a test host entry pointing to
203.0.113.10. Replace that example IP with your real server IP. - Apache installed from FreeBSD packages.
- A Python application that can listen on
127.0.0.1:8000.
We will use the reserved documentation address 203.0.113.10 in commands. Replace it with your real public IP before you run anything on your server.
Connect and confirm FreeBSD
On your local computer
ssh root@203.0.113.10203.0.113.10 is a documentation example and must be replaced with the real public IP assigned to your server. Keep the root session open while you build the non-root service account and test the proxy.
On the VPS as root
freebsd-versionThis confirms the FreeBSD release. You should see a valid version string such as 14.2-RELEASE.
Create a non-root account for the app
Running the Python process as root is a bad idea. Create a dedicated user, then give it ownership of the application files.
On the VPS as root
pw useradd deploy -m -s /bin/sh -G wheelThis creates the deploy account, makes a home directory, uses /bin/sh, and adds the user to wheel so it can use su or doas if you enable it later.
passwd deploySet a temporary password if you need password login during setup. If you use SSH keys only, you can lock the password after the first key-based login.
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chown -R deploy:deploy /home/deploy/.sshNow copy your public key into /home/deploy/.ssh/authorized_keys. From your local computer, append the public key with:
ssh-copy-id deploy@203.0.113.10If you do not use ssh-copy-id, paste the key manually on the server and then fix ownership and permissions:
cat > /home/deploy/.ssh/authorized_keys <<'EOF'
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIReplaceWithYourRealPublicKey deploy@example.com
EOF
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keysOpen a second terminal now and test the new login before you change anything else.
On your local computer
ssh deploy@203.0.113.10If that works, confirm the user can become root when needed.
On the VPS as the non-root sudo user
su -
pw usershow deployKeep the original root session open until this is verified. If you plan to use a locked password and SSH keys only, do that after the Apache proxy is working.
Install Apache and Python support on FreeBSD
FreeBSD uses pkg for package management. Install Apache, Python, and the WSGI bridge libraries that fit this deployment pattern.
On the VPS as root
pkg update
pkg install -y apache24 py311-python py311-virtualenvThis installs Apache 2.4 and a current Python 3.11 toolchain. The exact Python package may vary by FreeBSD release, but the 3.11 line is a safe current choice in 2026.
Check the versions so you know what is active.
httpd -v
python3.11 --versionApache should report its 2.4 build, and Python should report a 3.11.x version string.
Enable Apache modules and basic service start
Apache on FreeBSD reads its configuration from /usr/local/etc/apache24/httpd.conf. Before editing it, enable the service in rc.conf.
On the VPS as root
sysrc apache24_enable=YESThat makes Apache start after reboot. Now edit the main configuration file.
ee /usr/local/etc/apache24/httpd.confAdd or confirm these lines in the file. Keep the rest of the default file unless your server image has unusual settings.
ServerName server.example.com:80
Listen 80
LoadModule proxy_module libexec/apache24/mod_proxy.so
LoadModule proxy_http_module libexec/apache24/mod_proxy_http.so
LoadModule headers_module libexec/apache24/mod_headers.so
LoadModule rewrite_module libexec/apache24/mod_rewrite.so
IncludeOptional etc/apache24/extra/httpd-vhosts.confSave and exit ee with Esc, then a for save, then q to quit. Replace server.example.com with your real hostname if you already have DNS pointed at the server.
Before starting Apache, test the configuration.
On the VPS as root
apachectl configtestYou want Syntax OK. If the file contains a typo, Apache will tell you the line number before startup.
service apache24 startThen check that it is running.
service apache24 statusSuccessful output should show Apache as active.
Build a small Python app behind the proxy
We will create a minimal WSGI app that listens only on localhost. That keeps the application unreachable from the public network, which is the whole point of placing Apache in front of it.
On the VPS as the non-root sudo user
sudo -iu deploy
mkdir -p /home/deploy/myapp
cd /home/deploy/myapp
python3.11 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install gunicornThe app directory is /home/deploy/myapp in this example. If you prefer /opt/myapp, keep the same structure and permissions, but use that path consistently.
Create the app file.
cat > /home/deploy/myapp/app.py <<'EOF'
def app(environ, start_response):
body = b"Hello from Python behind Apache on FreeBSD\n"
headers = [("Content-Type", "text/plain; charset=utf-8"), ("Content-Length", str(len(body)))]
start_response("200 OK", headers)
return [body]
EOFNow create a tiny Gunicorn entry point. Gunicorn will serve the WSGI callable on localhost only.
cat > /home/deploy/myapp/wsgi.py <<'EOF'
from app import app
EOFTest the app manually on port 8000.
cd /home/deploy/myapp
. .venv/bin/activate
gunicorn --bind 127.0.0.1:8000 wsgi:appLeave that running for a moment in one terminal. From another terminal on the server, test it locally:
fetch -o - http://127.0.0.1:8000/You should see the hello message. Stop Gunicorn with Ctrl+C after the test.
Create a persistent FreeBSD service for the app
Use rc.d so the app survives a reboot. FreeBSD service scripts are different from Linux systemd units, and this is one place where the platform matters.
On the VPS as root
cat > /usr/local/etc/rc.d/myapp <<'EOF'
#!/bin/sh
# PROVIDE: myapp
# REQUIRE: LOGIN
# KEYWORD: shutdown
. /etc/rc.subr
name="myapp"
rcvar="myapp_enable"
pidfile="/var/run/${name}.pid"
command="/usr/local/bin/gunicorn"
command_args="--daemon --pid ${pidfile} --bind 127.0.0.1:8000 wsgi:app"
command_interpreter="/usr/local/bin/python3.11"
start_precmd="myapp_precmd"
myapp_precmd()
{
cd /home/deploy/myapp || return 1
su -m deploy -c "/usr/local/bin/gunicorn --daemon --pid ${pidfile} --bind 127.0.0.1:8000 wsgi:app"
}
load_rc_config $name
: ${myapp_enable:=no}
run_rc_command "$1"
EOF
chmod 555 /usr/local/etc/rc.d/myappThis script is intentionally simple. In a larger deployment you might use daemon, supervisord, or a dedicated process manager, but for a single app this is enough to show the full path from boot to proxy.
Enable and start the service.
sysrc myapp_enable=YES
service myapp start
service myapp statusIf the service does not stay up, inspect its process and logs.
ps aux | grep gunicorn
sockstat -4 -6 | grep 8000You should see Gunicorn bound to 127.0.0.1:8000.
Configure Apache as the reverse proxy
Now create a dedicated virtual host. This is the part that exposes your application to the public web while keeping the backend private.
On the VPS as root
ee /usr/local/etc/apache24/extra/httpd-vhosts.confUse this complete virtual host example.
<VirtualHost *:80>
ServerName server.example.com
ServerAlias www.server.example.com
ProxyPreserveHost On
ProxyRequests Off
RequestHeader set X-Forwarded-Proto "http"
ProxyPass / http://127.0.0.1:8000/
ProxyPassReverse / http://127.0.0.1:8000/
ErrorLog "/var/log/apache24/myapp-error.log"
CustomLog "/var/log/apache24/myapp-access.log" combined
</VirtualHost>Replace server.example.com with your real hostname. If your DNS is not ready yet, you can still test with a local hosts entry from your laptop.
Check the config again before reloading Apache.
apachectl configtest
service apache24 reloadUse configtest before the reload so a typo does not take the site offline.
Restrict the backend with pf
The Python app should not be reachable on the network, only from Apache on the same host. FreeBSD’s packet filter can enforce that.
On the VPS as root
ee /etc/pf.confUse a minimal ruleset like this if pf is not already in use by another policy.
set skip on lo0
block in all
pass out all keep state
pass in on vtnet0 proto tcp from any to any port 80 keep state
pass in on vtnet0 proto tcp from any to any port 22 keep stateIf your interface is not vtnet0, replace it with the real public NIC name from ifconfig. Then validate and enable pf carefully.
pfctl -nf /etc/pf.conf
sysrc pf_enable=YES
service pf start
pfctl -srThis keeps your local backend port closed to the outside while leaving SSH and HTTP available.
Verify the proxy path from server and client
First confirm the backend still answers only locally.
On the VPS as root
fetch -o - http://127.0.0.1:8000/You should see the hello response from the Python app.
Next, confirm Apache can reach it through the public address.
On your local computer
curl -I http://203.0.113.10/Replace 203.0.113.10 with your real server IP. You should receive a 200 OK or a normal Apache response if DNS is still settling.
Then test the full body.
curl http://203.0.113.10/The response should be the Python hello text served through Apache.
On the server, check the logs if anything looks wrong.
tail -n 50 /var/log/apache24/myapp-access.log
tail -n 50 /var/log/apache24/myapp-error.logThat will usually tell you whether the proxy path failed, the app stopped, or Apache returned a permission or module error.
Make the service survive a reboot
Before you call the setup complete, test persistence. Reboot the server, then confirm Apache and the app come back on their own.
On the VPS as root
service apache24 status
service myapp status
rebootAfter the reboot, reconnect from your local computer and re-run the status checks.
On your local computer
ssh root@203.0.113.10On the VPS as root
service apache24 status
service myapp status
sockstat -4 -6 | grep -E '(:80|:8000)'You want Apache listening on port 80 and Gunicorn listening only on 127.0.0.1:8000.
Safe rollback if you need to back out
If you need to stop the deployment quickly, reverse the changes in this order so you do not lock yourself out or leave a broken service enabled.
On the VPS as root
service apache24 stop
service myapp stop
sysrc -x apache24_enable
sysrc -x myapp_enableIf you added pf rules that block access too aggressively, restore the last known good pf configuration before restarting networking.
service pf stop
cp /etc/pf.conf /root/pf.conf.bad.$(date +%F-%H%M%S)Then put back your earlier pf.conf and validate it before re-enabling pf.
Troubleshooting the most likely failures
Apache will not start
Run:
apachectl configtest
service apache24 startIf configtest fails, fix the line number it reports. A missing quote in the virtual host file is the most common cause.
The app works on 127.0.0.1 but not through Apache
Run:
tail -n 50 /var/log/apache24/myapp-error.log
sockstat -4 -6 | grep 8000If Apache shows connection refused, the app is not running or is bound to the wrong address. Start the Gunicorn service again and confirm it listens on 127.0.0.1:8000.
Nothing is reachable from outside
Run:
pfctl -sr
ifconfigIf pf is blocking port 80 or the interface name is wrong, fix /etc/pf.conf, validate it with pfctl -nf /etc/pf.conf, and reload pf.
Permission errors in the app directory
Run:
ls -ld /home/deploy/myapp
ls -l /home/deploy/myappFiles should belong to deploy:deploy. Correct them with chown -R deploy:deploy /home/deploy/myapp.
Next step: add TLS after the proxy is stable
Once the plain HTTP path is working, add Let's Encrypt or another certificate method on top of Apache. That keeps the change set smaller and troubleshooting easier. If you later move this app to a larger Hostperl environment, the same proxy pattern scales cleanly on a dedicated server or a managed VPS without changing the application code.
If you want a straightforward, supportable path for Python apps, Hostperl VPS hosting gives you enough control to run Apache, FreeBSD services, and isolated backends without wasting time on edge cases. For higher traffic, Hostperl dedicated server hosting adds more headroom for TLS, logging, and future app growth.
Start with the right base platform, then keep the app private and the public edge predictable. That approach saves time during launches and migrations.
FAQ
Can I use this Apache reverse proxy pattern for Django or Flask?
Yes. The public-facing Apache layer stays the same. You only replace the Python app entry point with your real WSGI callable.
Why keep the Python app on 127.0.0.1 instead of a public port?
It reduces exposure. Apache handles the public connection, while the backend stays unreachable from the network.
Do I need pf if I already bind the app to localhost?
Binding to localhost is good, but pf gives you a second control layer. It is useful if a process changes its bind address later.
What should I check first if the proxy returns 502?
Check the Gunicorn process, then inspect Apache’s error log. A 502 usually means Apache cannot reach the backend.
Can I add TLS on the same FreeBSD server later?
Yes. Once the proxy is working, you can add certificates, redirect HTTP to HTTPS, and keep the backend unchanged.
