One Box, Many Apps: Docker and Traefik on a Fresh Ubuntu Server

I've stood up this exact setup enough times now that it's become muscle memory. A fresh Ubuntu box, Docker, Traefik in front, and within an hour a new app is live with a real HTTPS certificate, no config file touched.

The recipe doesn't change from one server to the next, and that sameness is the whole appeal.

What makes it boring in the good way is that it's built around a single rule, and once you internalise that rule the rest of the decisions make themselves.

The rule is this: no app ever publishes a port to the host. Not one. Everything an app needs to be reachable it gets by asking Traefik nicely, and Traefik is the only process on the machine listening on 80 and 443.

That one constraint is doing more work than it looks. It closes a firewall hole most people don't know they have, gives you automatic HTTPS for free, and lets a single cheap server run ten apps that know nothing about each other.

Let me walk you through the build the way I actually do it, and flag the two or three places that have cost me an afternoon so they don't cost you one.

Lock the door before you furnish the house

Before Docker, before Traefik, before anything fun, the box gets locked down. This part is short and it is not optional.

A server on the public internet is being probed within minutes of coming online, and the default state of a fresh box is not one you want to leave sitting there.

The first move is to stop logging in as root. Create an ordinary user, give it sudo, and do all your real work as that user:

Bash
adduser deploy
usermod -aG sudo deploy

Copy your SSH key up to the new user, then open /etc/ssh/sshd_config and turn off the two things attackers rely on: root login and password authentication.

Plain text
PermitRootLogin no
PasswordAuthentication no

Restart SSH and you're done here.

Bash
systemctl restart ssh

Key-only auth with no root login is the security that actually matters. It sounds almost too simple to count, but shutting the front door on password guessing and root logins removes the overwhelming majority of the automated noise that hits a public server.

Everything after this is plumbing.

Install Docker from the source that won't rot

It's tempting to apt install docker.io and move on. Don't. The version in Ubuntu's own repositories lags, and on a machine whose whole job is running containers you want the current engine and the compose plugin that ships with it. Use Docker's official repository instead.

Bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Then add your deploy user to the docker group so you're not typing sudo in front of every command:

Bash
sudo usermod -aG docker deploy

Log out and back in for that to take effect, and docker ps should run clean.

One honest caveat, because a lot of guides skip it: being in the docker group is effectively being root. Anyone who can run docker can mount the host's filesystem into a container and read or write anything on the machine.

That's not a flaw you're going to fix, it's just how the daemon works, and it's why the real security came from the previous section. The docker group is a convenience, not a sandbox.

The firewall Docker likes to ignore

Set up a firewall next. UFW makes this painless: allow SSH so you don't lock yourself out, allow the two web ports, and turn it on.

Bash
sudo apt-get install -y ufw
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Now here's the trap, and it's nasty because it fails silently. Docker manipulates iptables directly, and when you publish a container port with -p 8080:80 it punches that port straight through UFW.

Your firewall says the port is closed. The port is wide open.

You can spend a genuinely confusing afternoon staring at a ufw status that swears everything is locked down while a database container answers the whole internet.

There's a lot of advice about patching this with custom iptables rules or the ufw-docker tool. I don't bother, and this is exactly where the one rule earns its keep.

If no app ever publishes a port, there is no bypass to patch. The only ports open on the host are 80 and 443, both owned by Traefik, both deliberate.

The firewall and reality finally agree with each other. Remember that the next time you're tempted to add a quick -p to debug something.

One network to wire it all together

If apps don't publish ports, how does Traefik reach them? Over a shared Docker network. You create one external network, Traefik joins it, every app joins it too. That's the entire connective tissue of the setup:

Bash
docker network create web

The layout on disk mirrors that independence. Traefik lives in its own directory, each app in its own, and they are siblings, not nested. They are separate Compose projects that happen to share the web network:

Plain text
/home/deploy/
├── traefik/
│   ├── docker-compose.yml
│   ├── traefik.yml
│   └── acme.json
└── apps/
    ├── app1/
    │   └── docker-compose.yml
    └── app2/
        └── docker-compose.yml

Keeping them separate matters more than it seems. Traefik is infrastructure: it comes up once and stays up.

Your apps churn. You deploy them, restart them, tear them down and rebuild them. You don't want a docker compose down on one app to so much as breathe on the reverse proxy routing all the others.

Siblings on a shared network give you that isolation for free.

Traefik, the only thing actually listening

Traefik's config comes in two flavours, and it helps to know which is which. The static config is read once at startup: the ports Traefik listens on and how it gets certificates.

The dynamic config comes from your apps' Docker labels and changes constantly as containers come and go. Here's the static half, traefik/traefik.yml:

Plain text
api:
  dashboard: true

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: web

certificatesResolvers:
  letsencrypt:
    acme:
      email: [email protected]
      storage: /acme.json
      httpChallenge:
        entryPoint: web

Two lines there are doing a lot. The redirection under the web entry point bounces every plain HTTP request to HTTPS automatically, so you never write that redirect logic again.

And exposedByDefault: false flips Traefik to opt-in: a container is invisible to the proxy unless it explicitly asks to be routed.

That default is the safe one. You never accidentally expose something you forgot about.

The certificatesResolvers block is where the free HTTPS comes from. Traefik talks to Let's Encrypt, proves it controls the domain over the HTTP-01 challenge on port 80, and stores the resulting certificate in acme.json.

It renews them on its own, forever, with no cron job from you. Standard certs still run 90 days, and if you ever want to lean harder into automation, Let's Encrypt's six-day short-lived certificates went generally available in early 2026, though the default is fine for a setup like this.

Which brings me to Traefik's compose file, and the first gotcha that stops people before they start. Traefik won't boot unless its certificate store exists and is locked to owner-only permissions. Create it and set the mode before you run the stack:

Bash
cd /home/deploy/traefik
touch acme.json
chmod 600 acme.json

Miss that chmod and Traefik exits on startup, complaining the permissions are too open. It's a good instinct on their part, the file holds your private keys, but the error is easy to misread as something worse than "wrong file mode."

The dashboard has its own small trap. You protect it with HTTP basic auth, and you generate the credential with htpasswd:

Bash
sudo apt-get install -y apache2-utils
echo $(htpasswd -nB admin)

That produces a hash full of $ characters, which you drop into an .env file next to the compose file. Here's the catch: Docker Compose treats a single $ as the start of a variable, so it quietly mangles your hash. You have to double every dollar sign:

Bash
# /home/deploy/traefik/.env
DASHBOARD_AUTH=admin:$$2y$$05$$abcd......your.hash.here

Forget to double them and the auth silently fails to match, and you're left wondering why a password you're certain is correct keeps getting rejected.

One $ becomes two. That's the whole fix, obvious once you know and maddening until you do.

The version mismatch that stops you cold

This one cost me real time, so it gets its own section. You bring the whole thing up, Traefik starts, and then it can't see any of your containers. The logs carry this:

Plain text
ERR Failed to retrieve information of the docker client and server host
error="Error response from daemon: client version 1.24 is too old.
Minimum supported API version is 1.40, please upgrade your client to a newer version"
providerName=docker

Here's what happened. Recent Docker Engine releases raised the minimum API version they'll speak and dropped the old fallback that used to paper over version gaps.

Older Traefik v3 builds hardcode an ancient API version and never renegotiate, so the daemon takes one look and hangs up.

The maddening part is the obvious fix doesn't work: setting DOCKER_API_VERSION as an environment variable does nothing here, because Traefik constructs its Docker client before that variable is ever read.

The real fix is a Traefik new enough to auto-negotiate the API version with the daemon. That capability landed in v3.6.1, and the current supported line is v3.7, so that's what the compose file below pins rather than trusting latest:

Plain text
services:
  traefik:
    image: traefik:v3.7
    restart: unless-stopped
    command:
      - --configFile=/traefik.yml
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yml:/traefik.yml:ro
      - ./acme.json:/acme.json
    networks:
      - web
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=Host(`traefik.yourdomain.com`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=dashboard-auth"
      - "traefik.http.middlewares.dashboard-auth.basicauth.users=${DASHBOARD_AUTH}"

networks:
  web:
    external: true

Notice that Traefik is the one service in this entire setup that does publish ports, 80 and 443, and that's correct. Something has to face the internet.

The point of the rule was never "no open ports", it was "exactly one thing owns the open ports, on purpose."

Pin the image to a specific version and treat it as infrastructure you don't want silently jumping releases underneath you. Traefik only patches its latest minor line, so track it: a tutorial pinning an older v3.x today is quietly steering you onto a release already aging out of security fixes.

Then bring it up:

Bash
docker compose up -d
docker compose logs -f

Apps that announce themselves

With Traefik running, adding an app is almost anticlimactic, which is the goal. An app's compose file describes how to reach it entirely through labels, with no ports: section at all. Here's the minimal shape for an app at app1.yourdomain.com listening internally on port 3000:

Plain text
services:
  app1:
    image: ghcr.io/you/app1:latest
    restart: unless-stopped
    networks:
      - web
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app1.rule=Host(`app1.yourdomain.com`)"
      - "traefik.http.routers.app1.entrypoints=websecure"
      - "traefik.http.routers.app1.tls.certresolver=letsencrypt"
      - "traefik.http.services.app1.loadbalancer.server.port=3000"

networks:
  web:
    external: true

Read those labels top to bottom and they tell a complete story. The first opts the container in, since we set exposedByDefault: false. The Host rule says which domain routes here.

The websecure entry point puts it on 443, and the HTTP-to-HTTPS redirect from earlier handles port 80. The certresolver line tells Traefik to fetch and renew a Let's Encrypt cert for this hostname.

The last line names the container's internal port, the one the app actually listens on inside its own world, never a host port.

The app1 token repeated through those labels is just a name tying a router to a service. Make it unique per app and you can run as many apps as you like side by side, each with its own hostname and certificate, none aware the others exist.

Point the domain's DNS at the server, drop this file into ~/apps/app1/, run docker compose up -d, and Traefik notices the new container, requests the certificate, and starts routing.

No central registry to edit. The app announced itself.

One thing I'll flag and then leave alone: getting the image onto the box is a deployment concern, and the honest answer is to automate it with a CI pipeline rather than building on the server by hand.

That's its own topic, and I've written about treating deployment as a habit elsewhere. Here, assume the image exists and focus on the routing, which is the part Traefik owns.

Databases that never face the internet

Most apps need a database, and this is where the never-publish-a-port rule pays a second dividend. A database has no business being reachable from the internet, and here keeping it private isn't extra work, it's the default.

The trick is a second network that only the app and its database share:

Plain text
services:
  app1:
    image: ghcr.io/you/app1:latest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgres://app1:${DB_PASSWORD}@db:5432/app1
    networks:
      - web
      - internal
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app1.rule=Host(`app1.yourdomain.com`)"
      - "traefik.http.routers.app1.entrypoints=websecure"
      - "traefik.http.routers.app1.tls.certresolver=letsencrypt"
      - "traefik.http.services.app1.loadbalancer.server.port=3000"

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: app1
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app1
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app1"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

networks:
  web:
    external: true
  internal:

The app sits on two networks. It's on web so Traefik can route to it, and on internal so it can reach its database. The database is on internal only.

Traefik never sees it, no port is ever published, and because every app gets its own separate internal network, one app physically cannot reach another app's database.

Isolation by construction, not by a firewall rule you have to remember to write.

A couple of details in there earn their weight. The pgdata named volume keeps your data managed by Docker and decoupled from the project folder, so wiping and rebuilding the app never risks the database.

And the depends_on with condition: service_healthy, paired with the healthcheck, makes the app wait until Postgres is genuinely accepting connections rather than crash-looping against a database that hasn't finished starting.

Once data lives on the server it's yours to protect, so a nightly pg_dump pushed somewhere off the box is the one piece of homework I'll leave you with.

Why the sameness is the point

Strip it all back and what you have is a machine where exactly one process faces the internet, every app is invisible until it opts in, and every database is unreachable by design.

The firewall tells the truth. HTTPS renews itself.

Adding the eleventh app looks exactly like adding the first: drop a folder, write a handful of labels, point a domain, bring it up.

That predictability is the entire reason I keep coming back to this shape instead of something cleverer. Nothing bespoke to remember at 2am, nothing that drifts from one server to the next, no snowflake config that only made sense the week I wrote it.

It's the same boring recipe every time, and boring infrastructure is the kind you can still reason about a year later when something finally does break.

Rent a small Ubuntu box, run through this once, and you'll have a home for as many little apps as you care to build.

The first one takes an afternoon. Every one after that takes about ten minutes, and that's the payoff for doing the groundwork properly the first time.