This is a short story about how I attempted to deploy my own simple Django API with Traefik as a reverse proxy and proxied through Cloudflare, plus the frontend deployed on Cloudflare Pages. While a fairly straightforward process on paper, I gave up once or twice only to come back a week or two later.
You can check out this deployed web app at democracy.ryanfleck.ca
PaaS systems like Heroku spoil young developers by abstracting all of the networking, installation, and general pain associated with the deployment of web applications. While it’s fairly easy to see quick results, I’ve found ensuring you have a secure and durable deployment from a home server (aka for cheep) is rough. It took more than a couple days of reading docs and headbanging to figure out where and why bits and pieces of the solution were failing.
I know that writing this article dramatically compromises the security of my
home box, but I’m fairly certain at this point that only what is absolutely
necessary is on and enabled. Please reach out to [email protected]
if you’d
like to alert me to any fatal mistakes.
Alpine Linux and Docker
I chose to use Alpine Linux and Docker containers to build the foundations of my home server. After installing Alpine, adding Docker is as simple as:
apk add docker docker-compose
addgroup <username> docker
rc-update add docker boot
service docker start
Traefik and Portainer
Here’s my traefik.toml
- the important part of this configuration is the
providers.docker
section that allows us to list a network that will be watched
for new applications.
debug = false
checkNewVersion = true
[entryPoints]
[entryPoints.web]
address = ":80"
[entryPoints.web.http.redirections.entryPoint]
to = "websecure"
scheme = "https"
[entryPoints.websecure]
address = ":443"
[api]
dashboard = true
[certificatesResolvers.lets-encrypt.acme]
email = "****************"
storage = "/letsencrypt/acme.json"
[certificatesResolvers.lets-encrypt.acme.dnschallenge]
provider = "cloudflare"
[providers.docker]
watch = true
network = "web"
exposedbydefault = false
[acme]
entryPoint="https"
[acme.httpChallenge]
entryPoint="http"
In addition to Traefik I wanted dynamic DNS so my server’s public IP is always
updated in Cloudflare’s nameservers when my ISP inevitably changes it. To do
this I use a cloudflare-ddns
container, which is easy to set up.
version: '3'
networks:
web:
external: true
internal:
external: false
services:
cloudflare-ddns:
image: oznu/cloudflare-ddns:latest
restart: always
environment:
- API_KEY=**************************
- ZONE=********************
- SUBDOMAIN=server
- PROXIED=false
# https://lefthandbrain.com/traefik-wildcard-lets-encrypt/
traefik:
image: traefik:latest
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./traefik.toml:/traefik.toml
- ./traefik_dynamic.toml:/traefik_dynamic.toml
- letsencrypt:/letsencrypt
ports:
- 80:80
- 443:443
networks:
- internal
- web
environment:
- CLOUDFLARE_EMAIL=***************************
- CLOUDFLARE_DNS_API_TOKEN=**************************
volumes:
letsencrypt:
Finally, a nice UI to visualize all my docker containers would be nice. I chose
Portainer and highly recommend it, though don’t like deploying things with its
UI - I stick to docker-compose. These lines were added to my “administrative”
docker-compose.yml
file listed above.
portainer:
image: portainer/portainer-ce:latest
restart: always
ports:
- 9000:9000
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/portainer-data
environment:
- TZ="********************"
networks:
- internal
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.portainer.rule=Host(`portainer.ryanfleck.ca`)"
- "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=lets-encrypt"
…oh, but what are those labels at the bottom? Now that I’ve set up Traefik, any service I run can be selectively exposed to the internet as long as it exists on the web network and has this set of labels to selectively enable networking features.
Forward port 443 from your router to your server
after executing the following command to get your server online, and don’t
forget to add subdomain CNAME
records pointing towards your server’s A
record.
docker-compose up -d
Troubleshooting the Networking Configs
Spinning up the Django app was the easy part. Cookie-Cutter Django provides a flexible template with reasonable defaults, and with everything running on one box, requests between Django, Postgres, and Redis should be lightning fast.
By far, the most difficult part was figuring out if Traefik, Cloudflare, or Django itself was bungling my Access-Control-Allow-Origin headers. After quite a long time fiddling with Django’s CORS settings and searching for Cloudflare settings, I figured Traefik was the part of the networking spiderweb that was being restrictive, and for good reason.
Adding some Traefik middleware saved the day.
version: '3'
volumes:
production_postgres_data: {}
production_postgres_data_backups: {}
networks:
web:
external: true
internal:
external: false
services:
democracy-be-prod:
image: democracy_production_django
restart: always
build:
context: .
dockerfile: ./compose/production/django/Dockerfile
depends_on:
- postgres
- redis
env_file:
- ./.envs/.production/.django
- ./.envs/.production/.postgres
- ./.env
command: /start
ports:
- "5000:5000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.democracy-be-prod.rule=Host(`democracy-api.ryanfleck.ca`)"
- "traefik.http.routers.democracy-be-prod.entrypoints=websecure"
- "traefik.http.services.democracy-be-prod.loadbalancer.server.port=5000"
- "traefik.http.routers.democracy-be-prod.service=democracy-be-prod"
- "traefik.http.routers.democracy-be-prod.tls.certresolver=lets-encrypt"
- "traefik.http.middlewares.corsHeader.headers.accesscontrolallowmethods=GET,POST,OPTIONS,DELETE,PUT,PATCH"
- "traefik.http.middlewares.corsHeader.headers.accesscontrolalloworiginlist=https://democracy.ryanfleck.ca,https://democracy-api.ryanfleck.ca"
- "traefik.http.middlewares.corsHeader.headers.accesscontrolmaxage=100"
- "traefik.http.middlewares.corsHeader.headers.customresponseheaders.X-RCF-Test-Header=Bitchin"
- "traefik.http.middlewares.corsHeader.headers.accessControlAllowHeaders=*"
- "traefik.http.middlewares.corsHeader.headers.addvaryheader=true"
- "traefik.http.routers.democracy-be-prod.middlewares=corsHeader@docker"
networks:
- internal
- web
postgres:
image: democracy_production_postgres
restart: always
build:
context: .
dockerfile: ./compose/production/postgres/Dockerfile
volumes:
- production_postgres_data:/var/lib/postgresql/data:Z
- production_postgres_data_backups:/backups:z
env_file:
- ./.envs/.production/.django
- ./.envs/.production/.postgres
- ./.env
networks:
- internal
redis:
image: redis:5.0
restart: always
env_file:
- ./.envs/.production/.django
- ./.envs/.production/.postgres
- ./.env
networks:
- internal
The system was repeatedly brought down and back up with:
docker-compose -f production.yml --env-file .env up --force-recreate -d
So I tried to create an account, and… crap, I hadn’t run the migrations. Thought it’d do that for me. Oh well, that’s one step away.
It turns out that opening the docker continer directly with docker exec -it <container> /bin/bash
doesn’t load all the container’s environment variables.
My next instinct was to run the command through docker-compose like this:
docker-compose -f production.yml run --rm django python manage.py migrate
Bam, it works. The whole thing. Got the registration email in my inbox. Time for some user testing! Maybe I’ll deploy a few more apps.
…so, what did I learn?
- Restrictive networking by default is good.
- The above is painful until you know where the defenses are raised.
- Docker is a blessing.
Comments