Run several apps on one machine without port clashes. Give each app its own address, and let one program, Caddy, handle HTTPS for all of them.
Choose your front door covers how people reach your server from outside. This guide covers what happens once they arrive: how one machine tells ten apps apart.
The port problem
Every app listens on a port, a numbered door on the machine. Two apps cannot use the same door. Many popular apps pick the same default:
| App | Default port |
|---|---|
| Gogs | 3000 |
| Uptime Kuma | 3001 |
| AnythingLLM | 3001 |
| Navidrome | 4533 |
Uptime Kuma and AnythingLLM clash. With Docker you fix this by choosing a different port on the host side, for example "3002:3001" under ports:. The number before the colon is the host port. That works, but you end up with addresses like http://192.168.1.20:3002 and a list of numbers to remember. Browsers also treat each of these as a plain HTTP site, which causes trouble (see below).
The cleaner fix is a reverse proxy. It is one program that owns ports 80 and 443. It reads which name the visitor typed and passes the request to the right app. The apps keep their odd ports, and only the proxy faces the network.
One name per app
Give each app its own subdomain: music.example.com, status.example.com, git.example.com. The proxy routes on the name.
Avoid one name with paths, like example.com/music. Many apps assume they live at the root of a site. They write links such as /static/app.js that skip your /music prefix, so the page loads without its styles or scripts. Some apps can be told their prefix (Navidrome has a BaseUrl option for it), but as a rule of thumb, a subdomain works with every app and a sub-path works with some.
Domains and DNS
A domain name is a label that DNS, the internet's phone book, turns into an address. You rent a domain from a registrar and edit its records. Three record types matter here:
- An A record maps a name to an IPv4 address.
music.example.compoints to203.0.113.7. - A CNAME record maps a name to another name.
status.example.compoints tomusic.example.comand follows it. - A wildcard record has
*as its name, as in*.example.com. It answers for any subdomain that has no record of its own. One wildcard A record means a new app needs no DNS edit.
Changes are not instant. Each record has a TTL, the time other computers may keep the old answer. Wait a few minutes after an edit. Test with dig @1.1.1.1 music.example.com, which asks a public resolver directly. If dig is missing, install it with sudo apt install bind9-dnsutils.
Your home IP address can change. Your internet provider may reassign it without notice, and then your A record points at nothing. You can run a small updater on the server that rewrites the record (this is called dynamic DNS). Or you can avoid the problem. A tunnel dials out from home, so no record points at your home IP. A Tailscale address stays the same wherever the machine sits.
Why HTTPS matters even at home
A private app still needs HTTPS, because browsers enable some features only in a secure context, which in practice means an https:// page (or localhost). The list of restricted features includes service workers, the async Clipboard API and getUserMedia(), which opens the camera and microphone. A progressive web app (PWA) must be served over HTTPS (or from localhost) before the browser offers to install it as an app.
So an app at http://192.168.1.20:3002 often looks broken in odd ways. The copy button does nothing, the "install app" prompt never appears, and voice input fails. The address is the problem, not the app.
Caddy: the default for novices
Use Caddy. Its automatic HTTPS gets certificates for your names from a public authority such as Let's Encrypt, keeps them renewed, and redirects HTTP to HTTPS. You write no certificate settings at all.
Install it from the official Debian/Ubuntu repository. Then edit /etc/caddy/Caddyfile. Here is a complete file for two apps:
music.example.com {
reverse_proxy localhost:4533
}
status.example.com {
reverse_proxy localhost:3001
}
Check the file with caddy validate --config /etc/caddy/Caddyfile, then reload with sudo systemctl reload caddy. Each block says: for this name, send the traffic to this local port. Bind each app to the machine itself, as in "127.0.0.1:3001:3001" under ports: (the Uptime Kuma README shows this form), so the only way in is through the proxy.
For Caddy to get a certificate the usual way, it must prove you own the name. Caddy's default checks need port 80 or port 443 reachable from the internet, and in practice you open both. That means an open port, which Why you should not open ports warns about. If your apps are private, use one of the next two options instead.
Alternatives, in one line each. Traefik reads labels on your Docker containers and routes by itself, which suits people who already write Compose files. Nginx Proxy Manager gives you a web page to click through, so you need no config file.
HTTPS on a private network
You can have HTTPS without any open port. Two ways work.
Tailscale certificates. Tailscale can issue a Let's Encrypt certificate for a machine's .ts.net name. In the admin console, turn on HTTPS under the DNS page. Read its warning first: certificates go into a public log, and that log lists your machine names. Then run sudo tailscale serve --bg 3001. Serve proxies the local port and gives you an HTTPS address inside your tailnet. Without --bg it stops when you close the terminal or restart. The tailscale cert <name> command writes certificate files instead, but then you renew them yourself after 90 days. Caddy can also fetch these certificates from Tailscale for .ts.net names. The Caddy package runs as its own caddy user, so you must allow that user to fetch them with TS_PERMIT_CERT_UID=caddy.
A DNS challenge. With your own domain, Caddy can prove ownership by writing a temporary record at your DNS provider. This needs no open ports, and the name may point at a private address such as a Tailscale one. The price is setup: you give Caddy an API token for your DNS provider, and you need a Caddy build with that provider's module. This is the better path when you want music.example.com names that work only on your tailnet.
WebSockets must pass
Some apps keep a live two-way connection open to your browser. Uptime Kuma is built on WebSockets, and without them its pages load but never update. Caddy's reverse_proxy performs the WebSocket upgrade itself, so the two-line blocks above need nothing more. Other proxies may need explicit headers, so check when you choose one.
Apps that remember their own address
Some apps write their public address into their config at install time and use it for links, emails and redirects. If you install them at http://192.168.1.20:3000 and later move them behind https://git.example.com, they keep producing the old links. Set the address before first use.
- Gogs reads
EXTERNAL_URLandDOMAINfromapp.iniunder[server]. Its Docker guide usesEXTERNAL_URL = https://gogs.example.com/andDOMAIN = gogs.example.com. On the current release, 0.14.3, the first-run install page asks for these as "Domain" and "Application URL". - Huginn reads
DOMAINfrom its.envfile. The default islocalhost:3000, and the file says to change it for production. - Navidrome uses
BaseUrlonly if you serve it under a path. A whole subdomain needs nothing.
If a login redirects to the wrong place, look here first.
Git over SSH for Gogs
Gogs speaks two protocols. The web page goes through your proxy. Git over SSH does not. A Cloudflare tunnel can carry SSH, but each client then needs extra setup, such as cloudflared on the client too, so the web route alone is not enough for git.
Port 22 on your server already belongs to the host's own SSH, so Gogs needs another host port, here 2222. SSH_PORT in app.ini is the number Gogs shows in clone URLs. The rest depends on the image.
The standard gogs/gogs image runs its own SSH server on port 22 inside the container, and its docs say the built-in Gogs SSH server is not supported there. Publish "2222:22" and set:
[server]
SSH_PORT = 2222
The newer gogs/gogs:next-latest image has no separate SSH server. Turn on the built-in one and publish "2222:2222":
[server]
START_SSH_SERVER = true
SSH_PORT = 2222
SSH_LISTEN_PORT = 2222
Restart the container after the change. Clone with git clone ssh://git@git.example.com:2222/you/repo.git. Docker publishes this port on every interface, past ufw. On a VPS, block 2222 in the provider's firewall and reach it over Tailscale.
Checklist
- Pick one name per app, such as
music.example.com. Skip sub-paths. - Add one wildcard A record, or one record per app.
- Choose how HTTPS gets its certificate: open ports 80 and 443 for Caddy, a Tailscale certificate, or a DNS challenge.
- Bind each app to
127.0.0.1so only the proxy reaches it. - Write one Caddyfile block per app. Run
caddy validate, then reload Caddy. - Set the app's own address (
EXTERNAL_URL,DOMAIN) before first use. - For Git over SSH, use a port other than 22.
- Open each app and test the clipboard, install prompt and live updates.
Next: Before you share an app.