This is a guide on setting up a new server for self hosting in my preferred configuration. We’ll use Portainer to manage Docker Containers and Stack. And we’ll use Traefik as a reverse proxy configured to pull certificates from Cloudflare directly instead of LetsEncrypt. 

It also assumes a couple things

  1. You have a domain purchased through and/or connected to Cloudflare
  2. You have a computer or VM setup with Ubuntu Server, or any other Linux OS
  3. You know how to configure your router

Docker Setup

Install docker using the official guides according to your operating system. I always use Ubuntu Server. Once it’s installed, let’s setup Docker Swarm.  

sudo docker swarm init

If you wanted to setup a distributed file system like GlusterFS, now is it the time to do it. But going forward, we’ll just assume that it is setup, and you have a folder called docker_volumes at the root of your drive. If you don’t want to do that, make sure to edit docker volumes into the configuration files instead. 

Portainer Setup

Sometimes people like to setup Traefik first. Personally, I like to setup Portainer first that way Traefik can be managed by Portainer for easier configuration changes. 

Either way, we still need to make a traefik network before setting up Portainer. You can do that like this:

docker network create --driver=overlay traefik-public

Configuration File

version: "3.3"

services:
  agent:
    image: portainer/agent:sts
    environment:
      AGENT_CLUSTER_ADDR: tasks.agent
      DOMAIN: portainer.<YOUR DOMAIN HERE>
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /var/lib/docker/volumes:/var/lib/docker/volumes
    networks:
      - agent-network
    deploy:
      mode: global
      placement:
        constraints:
          - node.platform.os == linux

  portainer:
    image: portainer/portainer-ce:sts
    command: -H tcp://tasks.agent:9001 --tlsskipverify
    volumes:
      - /docker_volumes/portainer_portainer-data:/data
    ports:
      - target: 9443
        published: 9443
        mode: host
    networks:
      - agent-network
      - traefik-public
    environment:
      DOMAIN: portainer.<YOUR DOMAIN HERE>
    deploy:
      placement:
        constraints:
          - node.role == manager
      labels:
        - traefik.enable=true
        - traefik.docker.network=traefik-public
        - traefik.constraint-label=traefik-public
        - traefik.http.routers.portainer-http.rule=Host(`portainer.<YOUR DOMAIN HERE>`)
        - traefik.http.routers.portainer-http.entrypoints=http
        - traefik.http.routers.portainer-http.middlewares=https-redirect
        - traefik.http.routers.portainer-https.rule=Host(`portainer.<YOUR DOMAIN HERE>`)
        - traefik.http.routers.portainer-https.entrypoints=https
        - traefik.http.routers.portainer-https.tls=true
        - traefik.http.routers.portainer-https.tls.certresolver=cloudflare
        - traefik.http.services.portainer.loadbalancer.server.port=9000

networks:
  agent-network:
    driver: overlay
    attachable: true
  traefik-public:
    external: true

Explanation

This starts two services. An agent and the main service. The agent is deployed on all nodes in the swarm, while the main service sits on the manager node. Which is going to be the node you setup docker swarm from, or the node you’re probably on now. Note the docker deploy constraints. 

Another thing to look out for is our port configuration. This explicitly tells docker to open up Portainer to port 9000 and 9443. Don't open up these ports on your router. I believe this will also punch a hole through UFW, if you use that. Look up docker iptables to disable it.

There are a few labels related to Treafik. Those tell Traefik how to open up Portainer to the internet, but right now they don't do anything, at least until Traefik is setup.

Running Portainer

Copy the above contents into a yaml file called portainer_stack.yml on your server. Then run the following command to deploy it:

sudo docker stack deploy -c portainer_stack.yml portainer

You can check the status with sudo docker ps. At this point, you should be able to navigate to https://<SERVER IP>:9443 and setup Portainer.

Traefik Setup

Before getting to this step, make sure you've done the following:

  • Purchased a domain through Cloudflare you can use for all your self hosting services (I use dalenw.net).
  • Pointed a wildcard at your home IP address like *.dalenw.net
  • Said DNS entry is proxied
  • Port forwarding setup on your router for HTTP/HTTPS (80/443) to your manager node

Now let's setup Traefik. First thing we need is a hashed password to access the Traefik admin page that is stored as an environment variable.

$ export HASHED_PASSWORD=$(openssl passwd -apr1)
Password: $ enter your password here
Verifying - Password: $ re enter your password here

echo $HASHED_PASSWORD

Copy the results to a text file or something to store temporarily.

The other information we need is your Cloudflare email and API key. Here's how to get that:

  1. Go to your Cloudflare Profile
  2. Click on API Tokens on the left hand side
  3. Click on View under your Global API Key
  4. Write it down in temporary place

Before we can add the Traefik stack, there's one more thing we need to do. We need to add a node label to our manager node so that Traefik will always deploy to it.

  1. In Portainer, click on Swarm -> Details
  2. Click on the manager node
  3. Add a new node label. Name: traefik-public.traefik-public-certificates Value: true

In your Portainer instance, navigate to stacks and click on Add Stack. Call it traefik. Copy the following configuration file, then we'll add the environment variables.

version: "3.3"

services:
  traefik:
    # Use the latest Traefik image
    image: traefik:v2.11
    restart: always
    ports:
      # Listen on port 80, default for HTTP, necessary to redirect to HTTPS
      - target: 80
        published: 80
        mode: host
      # Listen on port 443, default for HTTPS
      - target: 443
        published: 443
        mode: host
    environment:
      USERNAME: admin
      DOMAIN: traefik.<YOUR DOMAIN HERE>
      EMAIL: ${CLOUDFLARE_EMAIL}
      CLOUDFLARE_EMAIL: ${CLOUDFLARE_EMAIL}
      CLOUDFLARE_API_KEY: ${CLOUDFLARE_API_KEY}
    deploy:
      placement:
        constraints:
          # Make the traefik service run only on the node with this label
          # as the node with it has the volume for the certificates
          - node.labels.traefik-public.traefik-public-certificates == true
      labels:
        - io.portainer.accesscontrol.public
        # Enable Traefik for this service, to make it available in the public network
        - traefik.enable=true
        # Use the traefik-public network (declared below)
        - traefik.docker.network=traefik-public
        # Use the custom label "traefik.constraint-label=traefik-public"
        # This public Traefik will only use services with this label
        # That way you can add other internal Traefik instances per stack if needed
        - traefik.constraint-label=traefik-public
        # admin-auth middleware with HTTP Basic auth
        # Using the environment variables USERNAME and HASHED_PASSWORD
        - traefik.http.middlewares.admin-auth.basicauth.users=admin:${HASHED_PASSWORD?Variable not set}

        # https-redirect middleware to redirect HTTP to HTTPS
        # It can be re-used by other stacks in other Docker Compose files
        - traefik.http.middlewares.https-redirect.redirectscheme.scheme=https
        - traefik.http.middlewares.https-redirect.redirectscheme.permanent=true
        # traefik-http set up only to use the middleware to redirect to https
        # Uses the environment variable DOMAIN
        - traefik.http.routers.traefik-public-http.rule=Host(`traefik.<YOUR DOMAIN HERE>`)
        - traefik.http.routers.traefik-public-http.entrypoints=http
        - traefik.http.routers.traefik-public-http.middlewares=https-redirect
        # traefik-https the actual router using HTTPS
        # Uses the environment variable DOMAIN
        - traefik.http.routers.traefik-public-https.rule=Host(`traefik.<YOUR DOMAIN HERE>`)
        - traefik.http.routers.traefik-public-https.entrypoints=https
        - traefik.http.routers.traefik-public-https.tls=true
        # Use the special Traefik service api@internal with the web UI/Dashboard
        - traefik.http.routers.traefik-public-https.service=api@internal
        # Use the "le" (Let's Encrypt) resolver created below
        - traefik.http.routers.traefik-public-https.tls.certresolver=cloudflare
        # Enable HTTP Basic auth, using the middleware created above
        - traefik.http.routers.traefik-public-https.middlewares=admin-auth
        # Define the port inside of the Docker service to use
        - traefik.http.services.traefik-public.loadbalancer.server.port=8080
    volumes:
      # Add Docker as a mounted volume, so that Traefik can read the labels of other services
      - /var/run/docker.sock:/var/run/docker.sock:ro
      # Mount the volume to store the certificates
      - /docker_volumes/traefik_traefik-public-certificates:/certificates
    command:
      # Enable Docker in Traefik, so that it reads labels from Docker services
      - --providers.docker
      # Add a constraint to only use services with the label "traefik.constraint-label=traefik-public"
      - --providers.docker.constraints=Label(`traefik.constraint-label`, `traefik-public`)
      # Do not expose all Docker services, only the ones explicitly exposed
      - --providers.docker.exposedbydefault=false
      # Enable Docker Swarm mode
      - --providers.docker.swarmmode
      # Create an entrypoint "http" listening on address 80
      - --entrypoints.http.address=:80
      # Create an entrypoint "https" listening on address 443
      - --entrypoints.https.address=:443

      # Create the certificate resolver "le" for Let's Encrypt, uses the environment variable EMAIL
      # - [email protected]
      # Store the Let's Encrypt certificates in the mounted volume
      # - --certificatesresolvers.le.acme.storage=/certificates/acme.json
      # Use the TLS Challenge for Let's Encrypt
      # - --certificatesresolvers.le.acme.tlschallenge=true

      ## Cloudflare configuration
      - --certificatesresolvers.cloudflare.acme.dnschallenge=true
      - --certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare
      - --certificatesresolvers.cloudflare.acme.email=${CLOUDFLARE_EMAIL}
      - --certificatesresolvers.cloudflare.acme.storage=/certificates/cloudflare.json


      # Enable the access log, with HTTP requests
      - --accesslog
      # Enable the Traefik log, for configurations and errors
      - --log
      # Enable the Dashboard and API
      - --api
    networks:
      # Use the public network created to be shared between Traefik and
      # any other service that needs to be publicly available with HTTPS
      - traefik-public
      # - traefik-internal

# volumes:
  # Create a volume to store the certificates, there is a constraint to make sure
  # Traefik is always deployed to the same Docker node with the same volume containing
  # the HTTPS certificates
  # traefik-public-certificates:

networks:
  # Use the previously created public network "traefik-public", shared with other
  # services that need to be publicly available via this Traefik
  traefik-public:
    external: true
  traefik-internal:
    driver: overlay

And don't forget about the environment variables for our new stack. Values should be self explanatory.

HASHED_PASSWORD=
CLOUDFLARE_EMAIL=
CLOUDFLARE_API_KEY=

It should be noted that I figured most of this out from this website: https://dockerswarm.rocks/traefik/#preparation, I've just tweaked it here and there to fit my use case.

Now traefik is running. And if everything works, you should now be able to access traefik.yourdomain.net and portainer.yourdomain.net proxied behind Cloudflare.

💡
It's highly recommended you hide those services from the public internet via Cloudflare Access.

Traefik Labels Explained

Traefik is a reverse proxy. It does a lot for you, and at the end of the day, connecting a container to be accessible from the internet from anywhere only has a couple requirements.

  1. The container or service is on the traefik-public network. If you have a service that runs multiple containers, then traffic will be distributed evenly across them.
  2. The container has the appropriate traefik configuration labels to tell traefik what to do.

These are the labels for Portainer:

deploy:
  labels:
    - traefik.enable=true
    - traefik.docker.network=traefik-public
    - traefik.constraint-label=traefik-public
    - traefik.http.routers.portainer-http.rule=Host(`portainer.<YOUR DOMAIN HERE>`)
    - traefik.http.routers.portainer-http.entrypoints=http
    - traefik.http.routers.portainer-http.middlewares=https-redirect
    - traefik.http.routers.portainer-https.rule=Host(`portainer.<YOUR DOMAIN HERE>`)
    - traefik.http.routers.portainer-https.entrypoints=https
    - traefik.http.routers.portainer-https.tls=true
    - traefik.http.routers.portainer-https.tls.certresolver=cloudflare
    - traefik.http.services.portainer.loadbalancer.server.port=9000

There are two routers and a service. The router uses an entry point as declared in our traefik configuration. We have two, http & https. We also declare a load balancer to point it to the http port that portainer runs from. The last thing to point out is the certresolver. It's cloudflare, again as declared earlier in our treafik configuration.

At the end of the day, I simply copy and paste the label group and change the names. In this case, it would be replacing all instances of portainer with ghost if I was hosting a ghost blog or something.