Containers, Containers and Recipes!
Portainer, Docker, TLS, LetsEncrypt and Mealie
I’ve started using Linux in the late 90s/early 2000s. I had a bad ass AMD K6/2 running in my parents basement burning a whole in their wallet while building and rebuilding the linux kernel more times than I can count. I had anything from LAMP, mail server, ldap, spamassassin, vhosts and then at some point I decided that having a life was good too. I retired my home server in favor of a google workspace free account that server my immediate needs. No more missed emails because my server broke due to an overly ambitious system upgrade, or a drive going bad wiping out 3 years of emails where I stupidly had no backups of.
Now, I find my ‘nas’ server is mostly a document storage and I rarely host anything but recently discovered two things.
1. I’m really getting cooking and all the apps I find are really either terrible or want to charge you for silly things.
2. I discovered portainer which suddenly makes things a lot easier.
I’m going to somewhat ignore the whole backup bit which I still need to address but for now, I found a really cool pattern and I thought I’d share.
If you’re not familiar with docker, without going into all the mundane details of the full definition, suffice it to say that it’s essentially a way to encapsulate application in these components that they call containers that in theory are isolated from the rest of the server. There’s a lot of caveats around that but generally they make life a lot easier to deploy, destroy and re-create applications without affecting your server which made my life a lot more enjoyable as a developer, sysadmin and tech enthusiast in general.
Portainer.io was a recent discover that somehow I completely missed which allows someone to run and deploy docker stacks very easily.
Portainer allows a user to connect to a local docker stack, kubernetes cluster and so on. Now, one of my main hurdles till now was easy of use. I had deployed several docker-compose.yml around various servers but managing all those was annoying and setting up the app correctly was always difficult.
So minimum requirements.
TLS - Gotta have a security layer.
I need a URL that isn’t 192.168.x.x:84923 some internal IP and some port I will never remember.
At some point I had setup nginx on my home server which was basically not doing anything except printing a hello world. Though since that was already taken care of, I nuked nginx and instead installed portainer.
Step 1: Portainer the whale to rule them all
I started with a docker compose stack and brought portainer up
services:
portainer:
image: portainer/portainer-ce:latest
command: "--http-enabled"
ports:
- 9443:9443
volumes:
- data:/data
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
volumes:
data:
This will change a bit before we’re done but it gets us a way to connect to portainer. So, connecting to https://localhost:9443 or whatever IP you’re running this on will let you setup portainer.
The setup is pretty straight forward, hit next a few times and you can connect to your ‘local’ stack which will look something like this
This will enable us to run any application we want and have portainer manage it and expose it via some port mapping. If the server has any issues, it should restart the stack.
Okay that’s great but I’m trying to avoid those weird port numbers. So let’s fix a few things.
Step 2: Traefik - The Load Balancer with TLS
Traefik is a really nice load balancer that does far more than I really can get into without losing most of my audience. Let’s just say it excels at handling containers but what it does that I really like is that it can get me a SSL certificate with ease.
So what do we need to get that.
a DNS Name. I added an entry for my cloud.mydomain.com as a CNAME that goes to my nas server.
We need to update the configuration a bit to take advantage of this
services:
traefik:
image: "traefik:v3.1"
container_name: "traefik"
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.websecure.address=:443"
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
- "--entryPoints.web.address=:80"
- "--certificatesresolvers.myresolver.acme.email=someValidEmail@gmail.com"
ports:
- "443:443"
- "8080:8080"
volumes:
- "./letsencrypt:/letsencrypt"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
restart: unless-stopped
portainer:
image: portainer/portainer-ce:latest
command: "--http-enabled"
volumes:
- data:/data
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.portainer.rule=Host(`cloud.mydomain.com`)"
- "traefik.http.routers.portainer.entrypoints=websecure"
- "traefik.http.routers.portainer.tls.certresolver=myresolver"
- "traefik.http.services.portainer.loadbalancer.server.port=9000"
A few things to note that changed here.
Portainer no longer exposes port 9443 or really any port.
Traefik exposes port 80 and 443. It’s going to act as the gateway to all of our traffic.
Portainer got a new set of labels that we didn’thave before.
The Rule needs to match your DNS name.
The Port needs to match the internal container port. Not the mapped port but the port the container is listening on.
Now, instead of doing some internalIP:magicPortNumber, we can simply connect to
https://cloud.mydomain.com to load up Portainer. We should have a valid SSL Certificate in a few minutes and everything is great. Okay except so far I still haven’t installed anything except a way to install things. What was the point of all this… oh right.. to enable my cooking.
Step 3: Mealie - nom nom nom
Mealie is an Open source recipe manager, meal planner, grocery list tracker etc. If you’re not a fan, the alternative option is called Tandoor. Both are OSS and allow you to mostly do the same thing. I ended up using Mealie so here goes.
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v1.12.0 #
container_name: mealie
restart: always
deploy:
resources:
limits:
memory: 1000M #
volumes:
- mealie-data:/app/data/
environment:
# Set Backend ENV Variables Here
ALLOW_SIGNUP: "false"
PUID: 1000
PGID: 1000
TZ: America/Anchorage
MAX_WORKERS: 1
WEB_CONCURRENCY: 1
BASE_URL: https://food.mydomain.com
# Database Settings
DB_ENGINE: postgres
POSTGRES_USER: mealie
POSTGRES_PASSWORD: mealie
POSTGRES_SERVER: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: mealie
depends_on:
postgres:
condition: service_healthy
postgres:
container_name: postgres
image: postgres:15
restart: always
volumes:
- mealie-pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: mealie
POSTGRES_USER: mealie
healthcheck:
test: ["CMD", "pg_isready"]
interval: 30s
timeout: 20s
retries: 3
volumes:
mealie-data:
mealie-pgdata:
This gives us a basic install. Obviously change the passwords and such as appropriate. I decided to use postgres but use whatever you like. Now, we went to all this trouble to get TLS setup to allow us to just connect via Traefik. So let’s add the labels we need. We’ll attach these to mealie since we really don’t care about postgres.
labels:
- "traefik.enable=true"
- "traefik.http.routers.food.rule=Host(`food.mydomain.com`)"
- "traefik.http.routers.food.entrypoints=websecure"
- "traefik.http.routers.food.tls.certresolver=myresolver"
- "traefik.http.services.food.loadbalancer.server.port=9000"
Okay everything looks good, now in theory , we should be able to connect to https://food.mydomain.com and everything should work….right? RIGHT?
RIGHT?
Well, almost.
Step 3 - Networking woes
The food subdomain is not reachable. Having the exact same code in the host’s docker compose works fine so there’s something else going on.
Portainer has a few networks available.
You’ll notice that portainer is running in containers_default which means that any other container is unreacheable. So we need to bind it to the same network.
Please note, the containers_default might be called something else. In my case I created my deployment in a folder called containers, so the default network ended up with the auto-generated name of ‘containers_default’. If you ran your docker-compose stack from a folder called foobar your network is probably called foobar_defaults.
Here are the changes that were needed.
1. All services need to be added to `containers_defaults` and we need to define the external network.
Here’s the final version:
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v1.12.0 #
container_name: mealie
restart: always
networks:
- containers_default
deploy:
resources:
limits:
memory: 1000M
volumes:
- mealie-data:/app/data/
environment:
# Set Backend ENV Variables Here
ALLOW_SIGNUP: "false"
PUID: 1000
PGID: 1000
TZ: America/Anchorage
MAX_WORKERS: 1
WEB_CONCURRENCY: 1
BASE_URL: https://food.mydomain.com
# Database Settings
DB_ENGINE: postgres
POSTGRES_USER: mealie
POSTGRES_PASSWORD: secret
POSTGRES_SERVER: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: mealie
depends_on:
postgres:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.food.rule=Host(`food.mydomain.com`)"
- "traefik.http.routers.food.entrypoints=websecure"
- "traefik.http.routers.food.tls.certresolver=myresolver"
- "traefik.http.services.food.loadbalancer.server.port=9000"
postgres:
container_name: postgres
image: postgres:15
restart: always
networks:
- containers_default
volumes:
- mealie-pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: secret
POSTGRES_USER: mealie
healthcheck:
test: ["CMD", "pg_isready"]
interval: 30s
timeout: 20s
retries: 3
volumes:
mealie-data:
mealie-pgdata:
networks:
containers_default:
name: containers_default
external: true
Okay, re-running the stack and now we have….*drum roll*
https://food.mydomain.com which gives us:
So, now any docker compose stack can easily be added, all I need is to follow the same pattern with a valid DNS entry and bam new service available. Now all I need to is load it full of recipes and we have….
Perfection!
Next article will discuss backup and monitoring. Truth be told monitoring is a bit overkill, but I have a theromostat that’s misbehaving so I do want to look into that for different reasons. Backups though are very important if I’m going to start using my NAS as an actual server for services that matters.
Also, as a bonus here’s an example of a grocy setup.
services:
grocy:
image: lscr.io/linuxserver/grocy:latest
container_name: grocy
networks:
- containers_default
labels:
- "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`grocy.mydomain.com`)"
- "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.tls.certresolver=myresolver"
- "traefik.http.services.whoami.loadbalancer.server.port=80"
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
volumes:
- grocy_volume:/config
restart: unless-stopped
volumes:
grocy_volume:
networks:
containers_default:
name: containers_default
external: true
What’s your favorite app that you’ve wanted to setup at home?