Heroku is great but it's sometimes painful when your app isn't just in one single language. What I have is a project where the backend is Python (Django) and the frontend is JavaScript (Preact). The folder structure looks like this:
/ - README.md - manage.py - requirements.txt - my_django_app/ - settings.py - asgi.py - api/ - urls.py - views.py - frontend/ - package.json - yarn.lock - preact.config.js - build/ ... - src/ ...
A bunch of things omitted for brevity but people familiar with Django and preact-cli/create-create-app should be familiar.
The point is that the root is a Python app and the front-end is exclusively inside a sub folder.
When you do local development, you start two servers:
./manage.py runserver
- startshttp://localhost:8000
cd frontend && yarn start
- startshttp://localhost:3000
The latter is what you open in your browser. That preact
app will do things like:
const response = await fetch('/api/search');
and, in preact.config.js
I have this:
export default (config, env, helpers) => {
if (config.devServer) {
config.devServer.proxy = [
{
path: "/api/**",
target: "http://localhost:8000"
}
];
}
};
...which is hopefully self-explanatory. So, calls like GET http://localhost:3000/api/search
actually goes to http://localhost:8000/api/search
.
That's when doing development. The interesting thing is going into production.
Before we get into Heroku, let's first "merge" the two systems into one and the trick used is Whitenoise. Basically, Django's web server will be responsibly not only for things like /api/search
but also static assets such as / --> frontend/build/index.html
and /bundle.17ae4.js --> frontend/build/bundle.17ae4.js
.
This is basically all you need in settings.py
to make that happen:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
...
]
WHITENOISE_INDEX_FILE = True
STATIC_URL = "/"
STATIC_ROOT = BASE_DIR / "frontend" / "build"
However, this isn't quite enough because the preact
app uses preact-router
which uses pushState()
and other code-splitting magic so you might have a URL, that users see, like this: https://myapp.example.com/that/thing/special
and there's nothing about that in any of the Django urls.py
files. Nor is there any file called frontend/build/that/thing/special/index.html
or something like that.
So for URLs like that, we have to take a gamble on the Django side and basically hope that the preact-router
config knows how to deal with it. So, to make that happen with Whitenoise we need to write a custom middleware that looks like this:
from whitenoise.middleware import WhiteNoiseMiddleware
class CustomWhiteNoiseMiddleware(WhiteNoiseMiddleware):
def process_request(self, request):
if self.autorefresh:
static_file = self.find_file(request.path_info)
else:
static_file = self.files.get(request.path_info)
# These two lines is the magic.
# Basically, the URL didn't lead to a file (e.g. `/manifest.json`)
# it's either a API path or it's a custom browser path that only
# makes sense within preact-router. If that's the case, we just don't
# know but we'll give the client-side preact-router code the benefit
# of the doubt and let it through.
if not static_file and not request.path_info.startswith("/api"):
static_file = self.files.get("/")
if static_file is not None:
return self.serve(static_file, request)
And in settings.py
this change:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
- "whitenoise.middleware.WhiteNoiseMiddleware",
+ "my_django_app.middleware.CustomWhiteNoiseMiddleware",
...
]
Now, all traffic goes through Django. Regular Django view functions, static assets, and everything else fall back to frontend/build/index.html
.
Heroku
Heroku tries to make everything so simple for you. You basically, create the app (via the cli or the Heroku web app) and when you're ready you just do git push heroku master
. However that won't be enough because there's more to this than Python.
Unfortunately, I didn't take notes of my hair-pulling excruciating journey of trying to add buildpacks and hacks and Procfile
s and custom buildpacks. Nothing seemed to work. Perhaps the answer was somewhere in this issue: "Support running an app from a subdirectory" but I just couldn't figure it out. I still find buildpacks confusing when it's beyond Hello World. Also, I didn't want to run Node as a service, I just wanted it as part of the "build process".
Docker to the rescue
Finally I get a chance to try "Deploying with Docker" in Heroku which is a relatively new feature. And the only thing that scared me was that now I need to write a heroku.yml
file which was confusing because all I had was a Dockerfile
. We'll get back to that in a minute!
So here's how I made a Dockerfile
that mixes Python and Node:
FROM node:12 as frontend
COPY . /app
WORKDIR /app
RUN cd frontend && yarn install && yarn build
FROM python:3.8-slim
WORKDIR /app
RUN groupadd --gid 10001 app && useradd -g app --uid 10001 --shell /usr/sbin/nologin app
RUN chown app:app /tmp
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
gcc apt-transport-https python-dev
# Gotta try moving this to poetry instead!
COPY ./requirements.txt /app/requirements.txt
RUN pip install --upgrade --no-cache-dir -r requirements.txt
COPY . /app
COPY --from=frontend /app/frontend/build /app/frontend/build
USER app
ENV PORT=8000
EXPOSE $PORT
CMD uvicorn gitbusy.asgi:application --host 0.0.0.0 --port $PORT
If you're not familiar with it, the critical trick is on the first line where it builds some Node with as frontend
. That gives me a thing I can then copy from into the Python image with COPY --from=frontend /app/frontend/build /app/frontend/build
.
Now, at the very end, it starts a uvicorn
server with all the static .js
, index.html
, and favicon.ico
etc. available to uvicorn
which ultimately runs whitenoise
.
To run and build:
docker build . -t my_app docker run -t -i --rm --env-file .env -p 8000:8000 my_app
Now, opening http://localhost:8000/
is a production grade app that mixes Python (runtime) and JavaScript (static).
Heroku + Docker
Heroku says to create a heroku.yml
file and that makes sense but what didn't make sense is why I would add cmd
line in there when it's already in the Dockerfile
. The solution is simple: omit it. Here's what my final heroku.yml
file looks like:
build:
docker:
web: Dockerfile
Check in the heroku.yml
file and git push heroku master
and voila, it works!
To see a complete demo of all of this check out https://github.com/peterbe/gitbusy and https://gitbusy.herokuapp.com/