Crippled by Heroku No More!

Published October 11, 2022 · 922 words · 5 minute read

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