Exposing a self-hosted application to the internet is one of the most common ways home labs and small servers get compromised. Many apps ship with weak or no authentication, their login pages are constantly scanned by bots, and a single unpatched service can give an attacker a foothold on your network. This guide shows you how to expose a service safely using a zero-trust approach: nothing is reachable without passing through the proxy, the proxy requires an identity check with multi-factor authentication (MFA), and the firewall allows only what is necessary.
The guide is written for home-lab operators and sysadmins who run a Linux host with Docker and want a practical, auditable setup. The example stack uses Caddy as the reverse proxy (it handles TLS certificates automatically) and Authelia as the identity-aware authentication gateway. The principles apply to other tools, such as Traefik with a different forward-auth provider, nginx with an auth_request setup, or a hosted identity service. Where exact configuration keys depend on the version you install, check the current project documentation before you copy anything.
Before you start
- A Linux server (Debian or Ubuntu used in the examples) with root or sudo access.
- Docker and Docker Compose installed.
- A domain name you control, with DNS A (and optionally AAAA) records pointing at your server’s public IP. Replace YOUR_DOMAIN throughout with your real domain.
- A router or NAT setup that forwards TCP ports 80 and 443 to the server. Port 80 is needed for certificate validation and redirects.
- An MFA app on your phone (any TOTP app works, such as a standard authenticator app).
- The public IP address you will administer from, referred to here as YOUR_ADMIN_IP. If your IP changes, consider a VPN instead of SSH over the internet.
- A backup of anything important on the server before you change firewall or SSH settings.
Step 1: Harden SSH access first
Before exposing anything, make sure you cannot lock yourself out or leave the most common entry point weak. Confirm you have a working SSH key login in a second terminal session before you change anything.
Create a drop-in configuration file so your changes survive package updates:
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf > /dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
EOF
sudo sshd -t && sudo systemctl reload sshThe sshd -t check validates the configuration before the reload. If it reports an error, fix it before reloading. Keep your original session open until you have confirmed a new login works.
Step 2: Lock down the firewall with default deny
The principle is simple: deny all inbound traffic by default, then open only the ports you need. With UFW on Debian or Ubuntu:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from YOUR_ADMIN_IP to any port 22 proto tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verboseRestricting SSH to YOUR_ADMIN_IP removes the server’s SSH port from the wide internet. Only ports 80 and 443 are open to everyone, and those should only be served by the reverse proxy.
Step 3: Design the network so applications are never published directly
The most important architectural rule is that your application containers should not publish ports to the host. Traffic must enter through the proxy. Create a Docker network for the proxy and the protected services, and keep the application on that network only.
docker network create proxy_netYour application’s compose file should look like this, with no ports: section:
services:
app:
image: YOUR_APP_IMAGE
restart: unless-stopped
networks:
- proxy_net
networks:
proxy_net:
external: trueThe application is now reachable only by containers on proxy_net. Anyone scanning your public IP on port 8080 or any other application port gets nothing.
Step 4: Deploy Caddy with automatic TLS
Caddy obtains and renews certificates from a public certificate authority automatically, and redirects HTTP to HTTPS by default. Create a directory and a compose file for the proxy:
mkdir -p ~/proxy && cd ~/proxy
cat > compose.yaml <<'EOF'
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- proxy_net
volumes:
caddy_data:
caddy_config:
networks:
proxy_net:
external: true
EOFNow write the Caddyfile. It defines the public site, applies security headers, and sends every request through the authentication gateway before it reaches the application. The auth subdomain will host the login portal.
YOUR_DOMAIN {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy no-referrer
X-Frame-Options DENY
-Server
}
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
reverse_proxy app:8080
}
auth.YOUR_DOMAIN {
reverse_proxy authelia:9091
}Note that forward_auth runs before every request to the application. If the gateway says the user is not authenticated, Caddy redirects them to the login portal and the application never sees the request.
Start the proxy and confirm certificates are issued:
docker compose up -d
docker compose logs -f caddyLook for log lines indicating a certificate was obtained for YOUR_DOMAIN. If issuance fails, the most common causes are DNS not yet pointing at your IP or port 80 not reaching the server.
Step 5: Add identity-aware authentication with MFA
Authelia acts as the gateway. Users sign in once on the portal, complete a second factor, and receive a session cookie scoped to your domain. The proxy checks that session on every request. Deploy it alongside Caddy:
mkdir -p ~/proxy/authelia && cd ~/proxy/authelia
cat > compose.yaml <<'EOF'
services:
authelia:
image: authelia/authelia:latest
restart: unless-stopped
volumes:
- ./config:/config
networks:
- proxy_net
networks:
proxy_net:
external: true
EOF
mkdir -p configPin a specific image tag once you have chosen a version, rather than relying on latest, so upgrades happen on purpose.
Generate a password hash for your first user. Do not put the plaintext password in a file or shell history if you can avoid it. Use the command documented by the Authelia release you installed. In recent versions it looks like this:
docker run --rm authelia/authelia:latest authelia crypto hash generate argon2 --password 'CHOOSE_A_LONG_PASSPHRASE'Create a users file in config/users_database.yml using the generated hash, then write the main configuration in config/configuration.yml. The essential security decisions are:
- Default deny: set the access control default policy to deny, and explicitly allow only the domains you intend to protect.
- Two-factor policy: require
two_factorfor the application’s domain, not justone_factor. - Secrets: generate long random values for every secret field (for example with
openssl rand -hex 64), and store the file with restricted permissions. - Session domain: set the session cookie domain to YOUR_DOMAIN so the cookie is shared between the portal and the protected app.
A minimal access rule for the protected site looks like this in outline form. Confirm the exact key names against the documentation for your installed version:
access_control:
default_policy: deny
rules:
- domain: YOUR_DOMAIN
policy: two_factorStart the gateway and enroll your TOTP device the first time you sign in at https://auth.YOUR_DOMAIN:
docker compose up -d
docker compose logs -f autheliaAfter enrolling, sign out and back in to confirm the second factor is required. Store your recovery codes somewhere safe, offline.
Step 6: Reduce the blast radius with extra controls
Authentication is only one layer. A few additional controls limit damage if something still goes wrong:
- Run containers as non-root where the image supports it, and drop unneeded capabilities with
cap_drop: [ALL]in the compose file for services that do not need them. - Make root filesystems read-only with
read_only: truewhere the application allows, adding tmpfs mounts for paths that need writes. - Set resource limits such as
mem_limitso a runaway process cannot take down the host. - Enable automatic security updates for the host operating system using your distribution’s unattended-upgrades or equivalent package.
- Log authentication events and review them. Failed login bursts against the portal are a signal worth alerting on.
Step 7: Reduce exposure further with a private-access option
If the service only needs to be used by you and a few trusted people, consider not exposing it publicly at all. A private mesh VPN such as WireGuard-based tools lets you reach the application only from enrolled devices. This is the strongest form of zero-trust exposure for home labs, because the public attack surface drops to zero. You can still combine it with the proxy and MFA for defense in depth.
Verify it worked
Testing is what separates a hardened setup from one you only believe is hardened. Run these checks from both the server and an outside network (a phone on mobile data works well).
Check the firewall and listening ports on the server:
sudo ufw status verbose
ss -tlnpOnly ports 22 (restricted to your admin IP), 80, and 443 should be reachable from the internet. The application port should not appear as a published port.
Confirm the application is not published directly:
docker ps --format 'table {{.Names}}\t{{.Ports}}'The application row should show no host port mapping.
Scan your public IP from outside your network:
nmap -Pn -p 1-65535 YOUR_PUBLIC_IPExpect 80 and 443 only, plus SSH only if you are scanning from YOUR_ADMIN_IP. Run the scan with permission only against systems you own.
Confirm unauthenticated requests are redirected to login:
curl -sI https://YOUR_DOMAIN | head -n 5You should see a redirect (a 302 response) pointing at the auth subdomain, not a 200 response with application content.
Confirm security headers are present:
curl -sI https://auth.YOUR_DOMAIN | grep -i -E 'strict-transport|x-content-type|x-frame|referrer'Check TLS configuration: run a dedicated tool such as testssl.sh against YOUR_DOMAIN and confirm it reports a valid certificate, no deprecated protocol versions, and a strong cipher configuration.
Check SSH: from a machine that is not YOUR_ADMIN_IP, confirm the SSH port is unreachable. From YOUR_ADMIN_IP, confirm key login works and password login is refused.
Common mistakes
- Docker bypasses UFW. When a container publishes a port with
-porports:, Docker writes its own firewall rules that take precedence over UFW. You can expose a service you thought was closed. Avoid publishing application ports, and verify with an outside scan after every change. - Forgetting the auth subdomain. If the portal is not reachable, every protected site fails to log in. Make sure auth.YOUR_DOMAIN has its own DNS record and proxy block.
- Leaving default or weak secrets. Example values copied from documentation end up in production more often than people admit. Generate every secret yourself.
- Using one factor for sensitive services. A password alone is not enough for anything that holds personal data or administrative controls.
- Skipping the lockout test. Lock yourself out of SSH by mistake and you learn the hard way. Always keep an existing session open while you test changes.
- Forgetting about updates. A hardened configuration decays if the images and host OS are not updated. Schedule regular pulls and reboots.
- Trusting the proxy headers blindly. Applications that accept identity headers such as Remote-User must only receive them from the proxy. Make sure the application port is unreachable except through the proxy, as Step 3 requires.
Takeaway
A safe public service is built from layers: a firewall that denies by default, an application that is reachable only through a proxy, automatic TLS, an identity gateway that requires a second factor, and a verification routine you repeat after every change. Each layer assumes the one before it might fail. Start with SSH and the firewall, keep application ports private, and test from outside your network every time you change something.
