Hosting many websites in a single VM using Docker

I like to test technologies and have many websites and other projects running. I used to have Windows VPS (still do, but currently migrating) and although updating websites was easy using Visual Studio's "Publish" functionality there were still some manual work like installing new versions of .NET or setting up SSL certificates for new websites. Paying for a Windows Server license wasn't nice either as it almost doubled the price of a cheap VPS.

So I decided to put my applications to containers and set up a Linux VM which could fulfill all these requirements

It turned out to be suprisingly easy and I ended up using these tools

Docker Engine

Docker Engine is an application for running Docker containers.
Just follow official installation instructions https://docs.docker.com/engine/install/

For Ubuntu

Uninstall old versions of Docker

sudo apt-get remove docker docker-engine docker.io containerd runc

Update package repository

sudo apt-get update
sudo apt-get install apt-transport-https ca-certificates curl gnupg lsb-release

Add Docker GPG key

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

Set up repository

echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Install Docker engine

sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io

Verify installation

sudo docker run hello-world

Docker Compose

Docker Compose is a tool for running multiple containers as a unit.
Again, follow official instructions https://docs.docker.com/compose/install/

For Ubuntu

Download

sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

Apply executable permissions

sudo chmod +x /usr/local/bin/docker-compose

Verify installation

docker-compose --version

Portainer & Traefik

Portainer is container management application.
Traefik is a reverse proxy and load balancer. It handles mapping domains to containers and their SSL certificates.

There's official guide https://documentation.portainer.io/v2.0/ad/traefik/rp-traefik/
Copy the docker-compose.yml template and modify with your information. I changed the router name to portainer (frontend in the official template). I also deleted the edge configuration as I don't need to manage many servers at the moment. Here's how my configuration looks (replace email and host).

version: "3.3"

services:
  traefik:
    container_name: traefik
    image: "traefik:latest"
    command:
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --providers.docker
      - --log.level=ERROR
      - --certificatesresolvers.leresolver.acme.httpchallenge=true
      - --certificatesresolvers.leresolver.acme.email=<YOUR_EMAIL>
      - --certificatesresolvers.leresolver.acme.storage=./acme.json
      - --certificatesresolvers.leresolver.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./acme.json:/acme.json"
    labels:
      - "traefik.http.routers.http-catchall.rule=hostregexp(`{host:.+}`)"
      - "traefik.http.routers.http-catchall.entrypoints=web"
      - "traefik.http.routers.http-catchall.middlewares=redirect-to-https"
      - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"

  portainer:
    image: portainer/portainer-ce:2.0.0
    command: -H unix:///var/run/docker.sock
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - portainer_data:/data
    labels:
      # Frontend
      - "traefik.enable=true"
      - "traefik.http.routers.portainer.rule=Host(`<YOUR_PORTAINER_HOST>`)"
      - "traefik.http.routers.portainer.entrypoints=websecure"
      - "traefik.http.services.portainer.loadbalancer.server.port=9000"
      - "traefik.http.routers.portainer.service=portainer"
      - "traefik.http.routers.portainer.tls.certresolver=leresolver"

volumes:
  portainer_data:

Create acme.json (stores SSL certificate information) file next to docker-compose.yml

touch acme.json

Now you can start the containers

docker-compose up -d

Add DNS record if not yet in place.

Navigate to the Portainer dashboard (<YOUR_PORTAINER_HOST> in the docker-compose.yml).

Create admin account.

Deploying applications

App templates

Portainer app templates have some common services like databases which are easy to set up.

Own images from Github

First of all your apps must be in containers and available as packages from github.

Create personal access token in Github

Go to settings (user settings in the top right corner, not repository settings)
Developer settings > Personal access tokens > Generate new token
Give it a name and scope read:packages

Create registry in Portainer

Registries > Add registry > Custom registry
Registry URL: docker.pkg.github.com/<GITHUB_USERNAME>
Username: <GITHUB_USERNAME>
Password: <GITHUB_PERSONAL_ACCESS_TOKEN>

Pull the image in Portainer

For some reason I had to pull the image (at least the first one from Github) before it could be used to create a container.
So open the Images tab and select the registry you just created and give the image name.

Create container

Now a new container can be created from the image.
Containers > Add container

If using Traefik for routing, insert labels.

- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443

Watchtower

Watchtower can update docker images automatically.

Containers > Create container
Registry: Dockerhub
Image: containrrr/watchtower:latest
Env:

Private registries

Follow https://containrrr.dev/watchtower/private-registries/ to set up access to private registry access.

E.g. for github, first create auth string

echo -n '<GITHUB_USERNAME>:<GITHUB_PERSONAL_ACCESS_TOKEN>' | base64

Then create a json config file with your username and secret (mine is /srv/management/config.json next to other config files docker-compose.yml and acme.json)

{
    "auths": {
        "https://docker.pkg.github.com/<GITHUB_USERNAME>": {
            "auth": "<SECRET>"
        }
    }
}

Last mount that file to watchtower container
Advanced container settings > Volumes
Container: /config.json
Type: bind
Host: <PATH_TO_CONFIG_FILE>

Updating only specific containers

If you put WATCHTOWER_LABEL_ENABLE: true to watchtower env then you can mark containers with label com.centurylinklabs.watchtower.enable: true to update them automatically.

Some gotchas

Put containers in the same network as Traefik if you want them to be accessible from the internet

Make sure Traefik router/service names are unique