Something I’ve wanted for a while now is a Platform as a Service but self-hosted so that I could install apps more efficiently on my VPS; something that could alleviate some of the hassle of setting up web apps and also keep me from having to install a bunch of extra runtimes that I’m not very familiar with.
My requirements were:
- Keep each app self-contained and not pollute the file system
- Automatically know how publish apps to the internet on install
- Not require me to manually ssh into the box to configure things by hand
Here’s what I came up with:
- Ansible to configure and install apps
- Docker containers housing all the installed apps
- Traefik running inside of Docker handling all web traffic
Gluing everything together
I was delighted to find that most of the apps I was using already had prebuilt docker images available. For security, all the container images I used had to have at least some kind of endorsement from the developers. It’s easy to tell if an open source app supports Docker by checking the source repository for a
Now, finding the Docker image isn’t the end of the process. Usually, I had to do a bit of configuring to match how I use the app, and some apps needed other services to run.
Docker’s solution for this is called Docker Compose. Docker Compose allows you to write a file describing which containers need to be started, which ports on those containers need to be exposed to the network, and where Docker should store the data that the container needs to persist.
I ended up needing to write about 2 files per app to describe how I wanted it to be setup. The first was a
docker-compose.yml file containing the application configuration, and the second was an Ansible playbook (
<app-name>.yml) that copied the
docker-compose.yml and any other configuration files for that app to the server and then started the containers using
docker-compose up. For some apps, the playbook needed to perform additional setup. Gitea, for example, needs an ssh passthrough configured to access git repositories over ssh without a special port.
My local directory structure for all these files looks something like this:
~/dev/ansible ├── docker │ ├── gitea │ │ ├── docker-compose.yml │ │ ├── gitea │ │ │ └── git │ │ └── passthrough_script │ ├── journey │ │ ├── data │ │ │ ├── config.json │ │ │ └── themes │ │ ├── docker-compose.yml │ │ └── Dockerfile │ ├── matrix-synapse │ │ ├── docker-compose.yml │ │ └── homeserver.yaml │ ├── nginx │ │ ├── docker-compose.yml │ │ ├── nginx.conf │ │ ├── sites-available │ │ │ ├── static-site │ │ │ └── another-static-site │ │ ├── sites-enabled │ │ │ ├── static-site │ │ │ └── another-static-site │ │ └── www │ │ ├── static-site │ │ └── another-static-site │ ├── riot │ │ ├── config.json │ │ └── docker-compose.yml │ └── traefik │ └── docker-compose.yml ├── docker_ubuntu.yml ├── gitea.yml ├── initial_server_setup.yml ├── inventory ├── journey.yml ├── matrix-synapse.yml ├── nginx.yml ├── riot.yml └── traefik.yml
Most of my playbooks looked like this:
--- - name: Install Journey in docker hosts: all remote_user: ansible tasks: - name: setup docker compose file copy: src: docker/journey dest: ~/docker - name: add images mountpoint file: path: ~/docker/journey/data/images state: directory - name: add database mountpoint file: path: ~/docker/journey/data/journey.db state: touch - name: Start journey docker_compose: build: yes project_src: docker/journey become: yes
docker-compose.yml files looked like this:
version: '3.7' services: journey: labels: - "traefik.http.routers.journeyTLS.rule=Host(`thoughtfuldragon.com`, `www.thoughtfuldragon.com`)" - "traefik.http.routers.journeyTLS.tls.certresolver=letsencrypt" - "traefik.http.middlewares.httpsredirect.redirectscheme.scheme=https" - "traefik.http.routers.journey.rule=Host(`thoughtfuldragon.com`, `www.thoughtfuldragon.com`)" - "traefik.http.routers.journey.middlewares=httpsredirect" - "traefik.docker.network=web" - "traefik.enable=true" build: . image: journey restart: unless-stopped ports: - 8084 volumes: - ./data/journey.db:/root/content/data/journey.db - ./data/config.json:/root/config.json - ./data/images:/root/content/images - ./data/themes:/root/content/themes networks: - web networks: web: external: true
Those lines under
labels tell Traefik how to route requests to the service. Some important things to note on how I setup Traefik:
- Traefik needs to share a Docker network with the containers it routes to. In my setup this is the
- Each Docker Compose file defines the domain and/or path that this app will be served on using Routers.
- Some of my apps use Middlewares to redirect to https or require authentication.
- I have configured Traefik to only expose containers which have it enabled. This improves security.
- Traefik can’t create DNS entries, so I set those up manually.
- I setup Traefik to get TLS certificates from Let’s Encrypt.
The Docker daemon notifies Traefik when a container starts. Traefik then reads the labels on the container and configures itself to serve requests to it.
Once I had written a
docker-compose.yml and Ansible playbook for an app, I ran
ansible-playbook <app-name>.yml -i inventory -l production
and after a few seconds I could access it in my browser and view it in the Traefik dashboard.
Except if I messed up somewhere
Usually it took several goes before I got everything right. I have a home server that I use as a testbed for my Docker containers. Once I’ve gotten stuff setup there, I generally know how to write the playbook, but sometimes that needs a few iterations as well.
When I finally decided I knew enough to put this system in production, I needed two full days to get everything setup. Most of that time was writing the Docker Compose and playbook files for the apps that I hadn’t gotten to during my preparations.
Ansible, Docker, and Traefik each automate a portion of the work needed to deploy apps. In the end, I still needed to spend time working out how to configure each application. Using these tools didn’t eliminate the need to install and configure the applications, but they did smooth out the process.
DigitalOcean Ansible Tutorials