A front-end deployment needs more than a development server. The application must be built into static assets, served efficiently, routed from a real domain, and protected with HTTPS. Docker packages those pieces consistently, while Traefik discovers the container, routes requests, and manages TLS certificates.

This guide deploys a typical Vite, React, Vue, or similar single-page app using a multi-stage Docker build, Nginx, Docker Compose, Traefik, and Let’s Encrypt.

How the stack fits together

The request path is deliberately simple:

  1. The browser connects to ports 80 or 443 on the server.
  2. Traefik redirects HTTP to HTTPS and selects a container from its routing labels.
  3. Traefik terminates TLS and forwards the request through a private Docker network.
  4. Nginx serves the compiled HTML, CSS, JavaScript, and other static assets.

The front-end container does not publish a host port. Only Traefik is exposed to the internet, which avoids port conflicts when more applications join the same server.

Prerequisites

You need a Linux server with Docker Engine and Docker Compose, plus:

Let’s Encrypt’s HTTP challenge requires the domain to reach Traefik on port 80. Wait for DNS to resolve correctly before starting the production stack.

Build a small production image

Use one image stage to install dependencies and build the application, then copy only the result into Nginx:

# syntax=docker/dockerfile:1
FROM node:24-alpine AS build

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM nginx:1.29-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html

EXPOSE 80

This is a multi-stage build: Node and the source code stay in the build stage, while the final image contains only Nginx and the compiled assets. If your tool outputs build rather than dist, change the source path in the last COPY.

Add a .dockerignore so local dependencies and build output are not sent to the Docker builder:

.git
node_modules
dist
build
.env*
npm-debug.log*

Front-end environment variables are usually embedded during the build. Never put a secret in a client-side variable: anything shipped to the browser can be read by a user.

Configure Nginx for client-side routing

A single-page app needs unknown paths such as /settings/profile to return index.html; the client router handles the route after that. Create nginx.conf beside the Dockerfile:

server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location ~* \.(?:css|js|mjs|jpg|jpeg|png|gif|svg|webp|ico|woff2?)$ {
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

The long cache lifetime is appropriate when the build tool fingerprints asset filenames. index.html is intentionally outside that rule so a new deployment can point users to the newest asset names.

Add Traefik and the app to Compose

Create compose.yaml in the project root:

services:
  traefik:
    image: traefik:v3.6
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=proxy
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.letsencrypt.acme.httpchallenge=true
      - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    networks:
      - proxy

  frontend:
    build:
      context: .
    restart: unless-stopped
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - "traefik.http.routers.frontend.rule=Host(`${APP_HOST}`)"
      - traefik.http.routers.frontend.entrypoints=websecure
      - traefik.http.routers.frontend.tls=true
      - traefik.http.routers.frontend.tls.certresolver=letsencrypt
      - traefik.http.services.frontend.loadbalancer.server.port=80

networks:
  proxy:
    name: proxy

volumes:
  letsencrypt:

There are several details worth keeping:

Docker API access is still security-sensitive even with a read-only mount. For a hardened or multi-tenant host, follow Traefik’s guidance and place an authorization layer or restricted socket proxy between Traefik and Docker.

Configure and deploy

Place non-secret deployment values in a .env file beside compose.yaml:

APP_HOST=app.example.com
ACME_EMAIL=ops@example.com

First render the final Compose configuration. This catches missing variables and YAML mistakes before containers change:

docker compose config

Then build and start the stack:

docker compose up -d --build
docker compose ps
docker compose logs --tail=100 traefik frontend

Verify both the redirect and the secure response:

curl -I http://app.example.com
curl -I https://app.example.com

The first request should redirect to HTTPS. The second should return the app through a trusted certificate after Let’s Encrypt validation completes.

For later releases, pull the newest code and run docker compose up -d --build again. Compose replaces the changed front-end container while leaving the certificate volume intact. In a larger delivery pipeline, build and scan the image in CI, push it to a registry, and deploy an immutable tag or digest rather than building on the server.

Common problems

Takeaways

This pattern starts small but scales cleanly: additional applications can join the same proxy network and define their own host rules without duplicating the TLS and ingress setup.

Sources