A complete web server built with Docker Compose. Includes Traefik, Prometheus, Grafana, Matomo, Remark42 and a Jekyll powered static website.
This project is a complete (and maybe overkill) web server environment for static websites. You can try to fully use it or to pick only a few elements. This project is not production grade. My main goal is to use it as a playground to improve my own skills and to test its integration with other web mini-projects. Any improvement suggestion will be greatly appreciated. I'm especially eager to learn on the securization and optimization aspects.
The dockerized ecosystem is composed of :
- Traefik as a reverse proxy.
- Prometheus to agregate metrics data.
- Grafana to display monitoring with dashboard, for example traefik and prometheus data.
- One or more basic static website(s) generated by Jekyll.
- Matomo and its MariaDB database, for web analytics.
- Remark42 to handle comments on the website(s)
I won't go into details on the reasons that made me choose one tool over another but I guess I could explain it with a few words. It's a mix between Open-Source, user personal data privacy, self-hosted solutions and ease of implementation and documentation.
You are going to need a couple of things to make this work:
- A Linux host with Docker and Docker Compose installed. Personally, I used a Windows 10 pc at home with a Ubuntu 20.04 Linux distribution installed through wsl and Docker Desktop during the development and testing phase. Afterwards, I used a Ubuntu VPS on a CSP for production.
- A public IP address (static).
- HTTP/HTTPS tcp ports (80 and 443) must be open to incoming traffic. If you're self-hosting at home you may have to use port forwarding and to configure your local firewall. I won't detail the process, it is well documented on the internet.
- A DNS domain with multiple subdomains. We will assign a subdomain to each published web service (Traefik, Prometheus, Grafana, Remark42, Matomo, website) and use them to setup Traefik routing. Make sure to add the DNS records of these published services by using the DNS Manager of your DNS provider and the host public IP address. The process is usually easy and well documented but depends on your DNS provider. For example, in the case of IONOS it is described there.
- The 2 Docker networks we will use for this project. You can create them with the following commands.
docker network create frontend
docker network create backend
Since we will be using Traefik to automatically discover our backend services, it will require access to the docker socket. This could be a security concern as explained in the Docker Daemon Attack Surface documentation. Traefik provides a few solutions in its Docker setup documentation and I chose to use the Docker socket proxy provided by Tecnativa.
Let's create the docker-compose.yaml
file at the root of our project.
version: "3.8"
services:
dockerproxy:
container_name: docker-proxy
environment:
CONTAINERS: 1
image: tecnativa/docker-socket-proxy
networks:
- backend
ports:
- 2375
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
networks:
backend:
external: true
That's it. With this configuration we have added the Docker proxy and it only allows the listing of the containers through the `CONTAINERS: 1` environment variable. Next step will be to configure Traefik to use the Docker proxy as a provider instead of the Docker socket file.
-
Create a
traefik
folder at the root of our project. -
Create Traefik configuration file
traefik.yaml
in this folder.api: dashboard: true debug: false entryPoints: http: address: ":80" https: address: ":443" providers: docker: endpoint: "tcp://docker-proxy:2375" watch: true exposedbydefault: false network: backend log: filePath: "/data/traefik.log" format: json level: WARN accessLog: filePath: "/data/traefik_access.log" format: json certificatesResolvers: http: acme: email: aandriano@myemail.fr storage: acme.json httpChallenge: entryPoint: http
We have :
- Enabled the dashboard.
- Created the http and https entrypoints.
- Configured Docker provider to use the Docker proxy as endpoint and the Docker
backend
network per default. Prevented Traefik from exposing all containers per default (only those with the labeltraefik.enable=true
will be exposed) - Enabled logs and access logs.
- Configured a
http
certificate resolver so Traefik can use an ACME provider like Let's Encrypt for automatic SSL certificate generation.
-
Now we must create the empty
acme.json
file in the traefik folder. It will be used to keep the automatically generated SSL certificates.Caution! You MUST set the permissions for this file to 600. If you don't you will get a self-explanatory error in your Traefik logs. If you are on a Windows environment you may have to use wsl or your
chmod 600 acme.json
command won't work. -
Next, we must add the Traefik service to the
docker-compose.yaml
file.services: traefik: depends_on: - dockerproxy image: traefik:v2.9.6 container_name: traefik restart: unless-stopped security_opt: - no-new-privileges:true networks: - frontend - backend ports: - 80:80 - 443:443 volumes: - /etc/localtime:/etc/localtime:ro - ./traefik/traefik.yaml:/traefik.yaml:ro - ./traefik/acme.json:/acme.json - ./traefik/traefik_access.log:/data/traefik_access.log - ./traefik/traefik.log:/data/traefik.log labels: - "traefik.enable=true" - "traefik.http.routers.traefik.entrypoints=http" - "traefik.http.routers.traefik.rule=Host(`$TRAEFIK_DOMAIN`)" - "traefik.http.middlewares.traefik-auth.basicauth.users=(`$TRAEFIK_BASIC_AUTH`)" - "traefik.http.routers.traefik.middlewares=traefik-https-redirect" - "traefik.http.middlewares.traefik-https-redirect.redirectscheme.scheme=https" - "traefik.http.routers.traefik-secure.entrypoints=https" - "traefik.http.routers.traefik-secure.rule=Host(`$TRAEFIK_DOMAIN`)" - "traefik.http.routers.traefik-secure.middlewares=traefik-auth" - "traefik.http.routers.traefik-secure.tls=true" - "traefik.http.routers.traefik-secure.tls.certresolver=http" - "traefik.http.routers.traefik-secure.service=api@internal"
I'm assuming you're already familiar with Docker and Docker Compose, that's why I will only explain the
labels
part. Let's see all of them :-
traefik.enable=true
: We are enabling this container. -
traefik.http.routers.traefik.entrypoints=http
: We are allowing requests from the http entrypoint defined in the config file. -
traefik.http.routers.traefik.rule=Host(
$TRAEFIK_DOMAIN)
: We are telling Traefik on which domain this service will respond. Here the value is defined in a separate.env
environment file. I will come back to this later. -
traefik.http.middlewares.traefik-auth.basicauth.users=(
$TRAEFIK_BASIC_AUTH)
: We have enabled the dashboard so we are adding the Traefik's Basic Auth middleware for authentication. The value is provided by the environment file. -
traefik.http.routers.traefik.middlewares=traefik-https-redirect
: We are adding a middleware to the http router. -
traefik.http.middlewares.traefik-https-redirect.redirectscheme.scheme=https
: We are asking to the previous middleware to redirect all HTTP incoming traffic to the HTTPS entrypoint. -
traefik.http.routers.traefik-secure.entrypoints=https
: We are allowing requests from the https entrypoint defined in the config file. -
traefik.http.routers.traefik-secure.rule=Host(
$TRAEFIK_DOMAIN)
: We are telling Traefik on which domain the secure routing will respond. -
traefik.http.routers.traefik-secure.middlewares=traefik-auth
: We are enabling the previously createdtraefik-auth
middleware on the secure router. This way we will be able to authenticate ourselves on the Traefik's dashboard with the appropriate credentials. -
traefik.http.routers.traefik-secure.tls=true
: We are enabling automatic TLS certificate generation. -
traefik.http.routers.traefik-secure.tls.certresolver=http
: We are telling Traefik to use the SSL certificate resolver specified in the configuration file. -
traefik.http.routers.traefik-secure.service=api@internal
: We are referencing a special service which is created automatically when the API is enabled (which is our case since we enabled the dashboard).
-
-
Finally we need to create the
.env
file I mentioned earlier at the root of the project to hold all the Compose environment variables.TRAEFIK_BASIC_AUTH=username:hashedpassword TRAEFIK_DOMAIN=your.traefik.domain.com
The username:hashedpassword value can be generated with the
htpasswd
tool. You will need to install theapache2-utils
package on your OS to use it.htpasswd -nb user password user:$apr1$sYbLhg33$u3k7DomrUjAvUYJ5inffF/
Do NOT use such obvious credentials, this is an example...
IMPORTANT : If the hashed string has any
$
you will need to modify them to be$$
or Docker Compose will think it's a variable. In the given example, the final credentials value will beuser:$$apr1$$sYbLhg33$$u3k7DomrUjAvUYJ5inffF/
and you will be able to authenticate yourself with the usernameuser
and the passwordpassword
.
-
Okay, at this point you can try to run :
docker-compose up -d
Traefik service should start with its dashboard available at https://your.traefik.domain.com. HTTPS should be enabled, automatically generating a TLS certificate in your
acme.json
file. All HTTP traffic should be redirected to HTTPS.Note : You may encounter some weird behaviour with Traefik log and access log files. If that's the case create them manually and restart your Docker environment.
I will quickly go through the process of creating a new static website with Jekyll before showing how to integrate it to our docker-compose.yaml
file. Feel free to skip the next part if you're not interested. At the end all you need is your website files into a website/_site
directory (create both directories if you didn't use Jekyll).
-
Install Jekyll
gem install bundler jekyll
-
Create a new Jekyll site with default theme
In project directory execute
jekyll new website
-
Generate site HTML code executing the command
jekyll build
HTML generated code is under _site
directory.
Feel free to read Jekyll documentation for more informations.
We will use a basic Apache image to mount our static website in the Docker container as a bind mount of /usr/local/apache2/htdocs
.
website:
depends_on:
- traefik
image: httpd:2.4-alpine
container_name: "website"
hostname: "website"
restart: unless-stopped
networks:
- backend
volumes:
- ./website/_site:/usr/local/apache2/htdocs/
ports:
- target: 80
protocol: tcp
labels:
- "traefik.enable=true"
- "traefik.http.routers.website.entrypoints=http"
- "traefik.http.routers.website.rule=Host(`$WEBSITE_DOMAIN`)"
- "traefik.http.middlewares.website-https-redirect.redirectscheme.scheme=https"
- "traefik.http.routers.website.middlewares=website-https-redirect"
- "traefik.http.routers.website-secure.entrypoints=https"
- "traefik.http.routers.website-secure.rule=Host(`$WEBSITE_DOMAIN`)"
- "traefik.http.routers.website-secure.tls=true"
- "traefik.http.routers.website-secure.tls.certresolver=http"
- "traefik.http.routers.website-secure.service=website"
- "traefik.http.services.website.loadbalancer.server.port=80"
Do not forget to add the WEBSITE_DOMAIN
value to your .env
file.
TRAEFIK_BASIC_AUTH=username:hashedpassword
TRAEFIK_DOMAIN=your.traefik.domain.com
WEBSITE_DOMAIN=your.website.domain.com
Now you can try to stop our dockerized environment and restart it.
docker-compose down -v
docker-compose up -d
Your static website should be available at https://your.website.domain.com
Now that we have our static website up and running behind Traefik reverse proxy, let's use Prometheus to collect metrics.
-
We need to update our
traefik.yaml
file to enable Prometheus and set it up. The official Traefik documentation on the matter is there.api: dashboard: true debug: false metrics: prometheus: addRoutersLabels: true addEntryPointsLabels: true addServicesLabels: true entryPoint: metrics entryPoints: http: address: ":80" https: address: ":443" metrics: address: ":8080" providers: docker: endpoint: "tcp://docker-proxy:2375" watch: true exposedbydefault: false network: backend log: filePath: "/data/traefik.log" format: json level: WARN accessLog: filePath: "/data/traefik_access.log" format: json certificatesResolvers: http: acme: email: aandriano@myemail.fr storage: acme.json httpChallenge: entryPoint: http
We have added the metrics entry point and configured Traefik to :
- Allow Prometheus for metrics.
- Enable metrics on routers, services and entry points.
- Use metrics entry point to expose its metrics.
-
Now let's create a
prometheus
directory at the root of our project and aprometheus.yaml
file inside.global: scrape_interval: 30s scrape_timeout: 10s evaluation_interval: 5s scrape_configs: - job_name: prometheus scheme: http static_configs: - targets: - prometheus:9090 - job_name: traefik scheme: http static_configs: - targets: - traefik:8080
With this configuration we are asking Prometheus to collect both its own metrics and Traefik metrics. This is a very basic setup, I highly recommend to read the official documentation, starting there.
-
We are going to add prometheus to our services in the
docker-compose.yaml
file.prometheus: image: prom/prometheus:v2.41.0 container_name: prometheus restart: unless-stopped networks: - backend volumes: - ./prometheus/:/etc/prometheus/ - /etc/localtime:/etc/localtime:ro - ./prometheus/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro command: - "--config.file=/etc/prometheus/prometheus.yaml" - "--storage.tsdb.path=/prometheus" - "--web.console.libraries=/usr/share/prometheus/console_libraries" - "--web.console.templates=/usr/share/prometheus/consoles" ports: - target: 9090 protocol: tcp labels: - "traefik.enable=true" - "traefik.http.routers.prometheus.entrypoints=http" - "traefik.http.routers.prometheus.rule=Host(`$PROMETHEUS_DOMAIN`)" - "traefik.http.middlewares.prometheus-https-redirect.redirectscheme.scheme=https" - "traefik.http.routers.prometheus.middlewares=prometheus-https-redirect" - "traefik.http.routers.prometheus-secure.entrypoints=https" - "traefik.http.routers.prometheus-secure.rule=Host(`$PROMETHEUS_DOMAIN`)" - "traefik.http.routers.prometheus-secure.tls=true" - "traefik.http.routers.prometheus-secure.tls.certresolver=http" - "traefik.http.routers.prometheus-secure.service=prometheus" - "traefik.http.services.prometheus.loadbalancer.server.port=9090"
-
Also add the 8080 port to traefik service configuration.
traefik: ... ports: - 80:80 - 443:443 - 8080:8080 ...
-
And don't forget to add the
PROMETHEUS_DOMAIN
value to your.env
file!TRAEFIK_BASIC_AUTH=username:hashedpassword TRAEFIK_DOMAIN=your.traefik.domain.com WEBSITE_DOMAIN=your.website.domain.com PROMETHEUS_DOMAIN=your.prometheus.domain.com
Now you can try to restart everything.
docker-compose down -v
docker-compose up -d
Prometheus should be available at https://your.prometheus.domain.com and you should see its metrics at https://your.prometheus.domain.com/metrics. There are also a lot of other possibilities available with Prometheus such as alerting etc. Check the doc!
Ok we got the metrics. But maybe they would be easier to exploit with some dashboards don't you think? Let's add Grafana to our stack.
-
First create a
grafana
directory. -
I'm providing an example of dasboard in this project. All you need to do is to copy the content of the
provisioning
folder from this repository into thegrafana
directory. The provided dashboard will use prometheus metrics as a datasource. You should read the Grafana official documentation on the matter. -
You will need to create a
grafana.env
file into thegrafana
directory.GF_AUTH_ANONYMOUS_ENABLED=true GF_AUTH_BASIC_ENABLED=false GF_AUTH_PROXY_ENABLED=false GF_USERS_ALLOW_SIGN_UP=false GF_INSTALL_PLUGINS=grafana-piechart-panel
We are overriding Grafana configuration with environment variables as explained there. This configuration is designed for a development/testing environment because it allows anonymous authentication. No login will be required to access to our dashboards.
-
Now let's add grafana service to our
docker-compose.yaml
grafana: image: grafana/grafana:9.3.2 restart: unless-stopped container_name: grafana volumes: - ./grafana:/var/lib/grafana - ./grafana/provisioning:/etc/grafana/provisioning - /etc/localtime:/etc/localtime:ro env_file: - grafana/grafana.env depends_on: - prometheus networks: - backend ports: - target: 3000 protocol: tcp labels: - "traefik.enable=true" - "traefik.http.routers.grafana.entrypoints=http" - "traefik.http.routers.grafana.rule=Host(`$GRAFANA_DOMAIN`)" - "traefik.http.middlewares.grafana-https-redirect.redirectscheme.scheme=https" - "traefik.http.routers.grafana.middlewares=grafana-https-redirect" - "traefik.http.routers.grafana-secure.entrypoints=https" - "traefik.http.routers.grafana-secure.rule=Host(`$GRAFANA_DOMAIN`)" - "traefik.http.routers.grafana-secure.tls=true" - "traefik.http.routers.grafana-secure.tls.certresolver=http" - "traefik.http.routers.grafana-secure.service=grafana" - "traefik.http.services.grafana.loadbalancer.server.port=3000"
-
Finally add the
GRAFANA_DOMAIN
value to your.env
file!TRAEFIK_BASIC_AUTH=username:hashedpassword TRAEFIK_DOMAIN=your.traefik.domain.com WEBSITE_DOMAIN=your.website.domain.com PROMETHEUS_DOMAIN=your.prometheus.domain.com GRAFANA_DOMAIN=your.grafana.domain.com
-
And restart everything.
docker-compose down -v docker-compose up -d
Grafana should be up at https://your.grafana.domain.com and if you go to the Dashboards panel you should see a traefik dashboard available. Start playing!
What about adding some web analytics solution to our website? Matomo is a free and open-source alternative to Google Analytics and this is the tool I have chosen. We will add 2 containers to our Docker environment. One for Matomo database and one for its frontend administration website.
-
First, let's create the directory structure we need in our project :
- Create a
matomo
directory at the root of the project. - In this
matomo
directory create adb
directory and awww-data
directory. We will use those as bind mounts for Matomo persistent data (database and website).
- Create a
-
Let's create Matomo's environment file
db.env
in thematomo
directory. It will be used by both containers.MYSQL_ROOT_PASSWORD=rootpassword MYSQL_DATABASE=matomo MYSQL_USER=matomo MYSQL_PASSWORD=password MATOMO_DATABASE_ADAPTER=mysql MATOMO_DATABASE_TABLES_PREFIX=matomo_ MATOMO_DATABASE_USERNAME=matomo MATOMO_DATABASE_PASSWORD=password MATOMO_DATABASE_DBNAME=matomo
NOTE : Do not keep those default password values !
-
Now we are going to add Matomo MariaDB database service to the
docker-compose.yaml
file.db: image: mariadb container_name: mariadb networks: - backend command: --max-allowed-packet=64MB restart: always volumes: - ./matomo/db:/var/lib/mysql env_file: - ./matomo/db.env
NOTE: I didn't invent anything here. This is taken from Matomo official Docker project
-
And now let's add the Matomo frontend container.
matomo: depends_on: - db image: matomo container_name: matomo restart: always networks: - backend volumes: - ./matomo/www-data:/var/www/html environment: - MATOMO_DATABASE_HOST=db env_file: - ./matomo/db.env ports: - target: 80 protocol: tcp labels: - "traefik.enable=true" - "traefik.http.routers.matomo.entrypoints=http" - "traefik.http.routers.matomo.rule=Host(`$MATOMO_DOMAIN`)" - "traefik.http.middlewares.matomo-https-redirect.redirectscheme.scheme=https" - "traefik.http.routers.matomo.middlewares=matomo-https-redirect" - "traefik.http.routers.matomo-secure.entrypoints=https" - "traefik.http.routers.matomo-secure.rule=Host(`$MATOMO_DOMAIN`)" - "traefik.http.routers.matomo-secure.tls=true" - "traefik.http.routers.matomo-secure.tls.certresolver=http" - "traefik.http.routers.matomo-secure.service=matomo" - "traefik.http.services.matomo.loadbalancer.server.port=80"
-
Now, don't forget to add the value for your Matomo domain in the main
.env
file.TRAEFIK_BASIC_AUTH=username:hashedpassword TRAEFIK_DOMAIN=your.traefik.domain.com WEBSITE_DOMAIN=your.website.domain.com PROMETHEUS_DOMAIN=your.prometheus.domain.com GRAFANA_DOMAIN=your.grafana.domain.com MATOMO_DOMAIN=your.matomo.domain.com
-
For the last part of the installation you will need to access the administration website which should be running after you restart you docker-environment.
docker-compose down -v docker-compose up -d
If everything went well you should now be able to access Matomo at https://your.matomo.domain.com. You can now finish the installation by following the official documentation.
-
To start tracking your website you will also have to include the Matomo javascript code snippet into your website pages. I won't go into the details here as it is a trivial step and well documented.
Finally, the last part of this project will be to add Remark42 which is a simple commenting engine so your website visitors can leave comments (usefull for a blog).
-
Create the following directories :
remark42
directory at the root of the project.var
directory intoremark42
directory. It will be used as a docker bind mount.
-
Create the environment file
.env
into theremark42
directory.REMARK_URL=https://your.remark42.domain.com SECRET=<remark42_secret> STORE_BOLT_PATH=/srv/var/db BACKUP_PATH=/srv/var/backup SITE=<site_id> AUTH_ANON=true
site_id
: It should be the same id than the one you will add to the Remark42 javascript code snippet which you will add to your website.AUTH_ANON=true
: We are allowing anonymous comment for testing purpose. You can remove this part later and add social or email logins. Read the official documentation there
-
Add remark42 service to the
docker-compose.yaml
fileremark42: image: umputun/remark42:v1.11.2 container_name: "remark42" hostname: "remark42" restart: unless-stopped networks: - backend volumes: - ./remark42/var:/srv/var ports: - target: 8080 protocol: tcp env_file: - ./remark42/remark42.env environment: - TIME_ZONE=Europe/Paris labels: - "traefik.enable=true" - "traefik.http.routers.remark42.entrypoints=http" - "traefik.http.routers.remark42.rule=Host(`$REMARK42_DOMAIN`)" - "traefik.http.middlewares.remark42-https-redirect.redirectscheme.scheme=https" - "traefik.http.routers.remark42.middlewares=remark42-https-redirect" - "traefik.http.routers.remark42-secure.entrypoints=https" - "traefik.http.routers.remark42-secure.rule=Host(`$REMARK42_DOMAIN`)" - "traefik.http.routers.remark42-secure.tls=true" - "traefik.http.routers.remark42-secure.tls.certresolver=http" - "traefik.http.routers.remark42-secure.service=remark42" - "traefik.http.services.remark42.loadbalancer.server.port=8080" - "traefik.http.middlewares.remark42.headers.accesscontrolallowmethods=GET,OPTIONS,PUT" - "traefik.http.middlewares.remark42.headers.accesscontrolalloworiginlist=*" - "traefik.http.middlewares.remark42.headers.accesscontrolmaxage=100" - "traefik.http.middlewares.remark42.headers.addvaryheader=true"
We have added a few extra Traefik labels here to resolve some CORS issues as explained here.
-
Add your Remark42 domain to the main
.env
fileTRAEFIK_BASIC_AUTH=username:hashedpassword TRAEFIK_DOMAIN=your.traefik.domain.com WEBSITE_DOMAIN=your.website.domain.com PROMETHEUS_DOMAIN=your.prometheus.domain.com GRAFANA_DOMAIN=your.grafana.domain.com MATOMO_DOMAIN=your.matomo.domain.com REMARK42_DOMAIN=your.remark42.domain.com
-
Restart everything.
docker-compose down -v docker-compose up -d
Congratulations we are done!
You should have your complete web environment up and running and you can start playing with it.
In my next project I plan to add automatized backups, a security layer for all those non-encrypted password, integrate this environment into a Kubernetes cluster and ease its deployment with Ansible. But this is another story.