Docker services that accept HTTP requests usually listen on an internal container port: for example, 3000, 8080, 5000, or 8000. This port is used inside the Docker network, while external access to the service is better organized through a domain, HTTPS, and a reverse proxy.
In a production environment, the user opens the service at:
https://app.example.com
The domain points to the public IP address of the VPS, Traefik accepts the request and forwards it to the internal Docker service.
The scheme looks like this:
User → https://app.example.com → Traefik → Docker service
1. Point the Domain to the VPS
First, create a DNS record for the domain or subdomain.
Example:
Type: A
Name: app
Value: SERVER_IP
If the domain is example.com, the service will be available at:
app.example.com
You can check DNS with the command:
dig app.example.com +short
Expected result:
SERVER_IP
2. Prepare the Docker Service
Assume the application runs inside the container on port 3000.
Example of a basic service:
services:
app:
image: your-app-image:latest
container_name: app
restart: unless-stopped
expose:
- "3000"
Here, expose is used instead of ports because the application does not need to be exposed directly to the internet. It will be available only inside the Docker network for Traefik.
Bad:
ports:
- "3000:3000"
Better:
expose:
- "3000"
Important
Do not publish the internal application port to the outside if access should go only through HTTPS and a reverse proxy.
3. Add Traefik as a Reverse Proxy
Below is an example of a docker-compose.yml file where Traefik publishes the application through HTTPS.
Replace:
app.example.com
admin@example.com
your-app-image:latest
with your own values.
services:
traefik:
image: traefik:v3.0
container_name: traefik
restart: unless-stopped
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.email=admin@example.com"
- "--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
app:
image: your-app-image:latest
container_name: app
restart: unless-stopped
expose:
- "3000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`app.example.com`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls=true"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
- "traefik.http.services.app.loadbalancer.server.port=3000"
networks:
- proxy
networks:
proxy:
name: proxy
Traefik uses routers, services, and entrypoints to process HTTP requests. In a Docker scenario, these parameters are usually set through container labels.
4. What This Configuration Does
In this example, Traefik:
- listens on ports 80 and 443;
- gets information about containers through the Docker socket;
- does not publish all containers automatically;
- looks only for containers with traefik.enable=true;
- accepts requests for app.example.com;
- issues an HTTPS certificate through Let’s Encrypt;
- proxies requests to the app:3000 container.
Key block for the application:
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`app.example.com`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls=true"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
- "traefik.http.services.app.loadbalancer.server.port=3000"
What this means:
traefik.enable=true
Traefik should process this container.
Host(`app.example.com`)
Requests to this domain are routed to the container.
entrypoints=websecure
The service works through the HTTPS entrypoint, that is, through port 443.
tls.certresolver=letsencrypt
A Let’s Encrypt certificate should be issued for this domain.
loadbalancer.server.port=3000
The internal application port inside the container is 3000.
Traefik supports ACME/Let’s Encrypt for automatic certificate issuance. In the official Traefik Docker Compose example with Let’s Encrypt, the certificate is issued through an HTTP challenge for a service published through Traefik.
5. Start the Services
Create a folder for certificates:
mkdir -p letsencrypt
Start the containers:
docker compose up -d
Check the status:
docker ps
The following containers should be running:
traefik
app
Check Traefik logs:
docker logs traefik
If DNS is configured correctly and ports 80/443 are available, Traefik will be able to issue an HTTPS certificate.
6. Open Ports in the Firewall
The VPS must have these ports open:
80/tcp
443/tcp
If UFW is used:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status
It is better to allow SSH only from a trusted IP:
sudo ufw allow from YOUR_TRUSTED_IP to any port 22 proto tcp
7. Check HTTPS
Open in the browser:
https://app.example.com
You can also check it with curl:
curl -I https://app.example.com
Expected result:
HTTP/2 200
or another successful response from the application.
Если открыть:
http://app.example.com
Traefik should redirect the request to HTTPS because the configuration includes a redirect from web to websecure:
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
Conclusion
To publish a Docker service through HTTPS on a VPS, you can use Traefik as a reverse proxy.
In a simple setup, the scheme looks like this:
Domain → VPS → Traefik → Docker service
The application does not need to be exposed directly through:
SERVER_IP:PORT
It is enough to keep it inside the Docker network and publish only ports 80 and 443 through Traefik.
Traefik is convenient for Docker services because routing is described directly in docker-compose.yml through labels. This is especially useful if you need to publish several services on one VPS:
app.example.com → app:3000
api.example.com → api:8080
panel.example.com → panel:9000
This approach is safer and more convenient for a production setup: the user opens the service through a domain with HTTPS, while the internal application port remains hidden.