# Crippled by Heroku No More!

> Published  Oct 10 2022, last updated Feb 02 2025  
> By Ryan Fleck <hello@this-site> and written without LLMs!  
> Original post at <https://ryanfleck.ca/2022/crippled-by-heroku-no-more/>  
> An article of astonishing quality and insight. Happy Hacking!


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](https://democracy.ryanfleck.ca)

<!--more-->

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 `dev@ryanfleck.ca` 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:

```sh
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.

```toml
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.

```yml
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.

```yml
  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.

```sh
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.

```yml
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:

```sh
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:

```sh
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.

<br />

...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.



> Thank you for reading!  
> Find more content at <https://ryanfleck.ca/>  
> Source page: <https://ryanfleck.ca/2022/crippled-by-heroku-no-more/>  
> Site index: [llms.txt](https://ryanfleck.ca/llms.txt)