Two Flask apps, frontend and admin, on one domain using DispatcherMiddleware

9 October 2019 Updated 16 October 2019 by Peter

Using Werkzeug's dispatcher middleware we combine two apps into a larger one with dispatching based on a prefix in the url.

post main image
unsplash.com/@ytcount

The Flask application I am writing to run this website has all code in a single 'app'. I already did some reorganizing as I wanted full separation of frontend code and the administration code. Now it it is time for total separation, meaning making the frontend a Flask app and the admin another Flask app while both running in the same domain and both reside in the same project directory. Because we do not want to duplicate code and data shared between both apps we create a 'shared directory' where the static items, the data model etc. lives.

The dispatcher middleware solution uses only one instance of gunicorn. Probably there are other ways to do this, e.g. having multiple instances of gunicorn, each serving an app but I did not investigate this.

Two apps

We have two Flask apps in the same project directory. One is called frontend and the other is called admin. Both apps are running on the same domain and the prefix 'admin' is used to send requests either to the frontend app or the admin app. Assume the port is 5000 then request:

http://127.0.0.1:5000/

is send to the frontend app and request:

http://127.0.0.1:5000/admin

is send to the admin app. Before applying the application dispatching to the actual application we first want to test if this is really working. For this I created a virtual environment and installed Flask and Gunicorn:

pip3 install flask
pip3 install gunicorn

In the virtual environment I created the following directory structure. This already shows the files that I will be using:


│
├── flask_dispatch
│   ├── bin
│   ├── include
│   ├── lib
│   ├── lib64
│   ├── share
│   ├── pyvenv.cfg
│   │
│   ├── project
│   │   ├── app_admin
│   │   │   └── __init__.py
│   │   ├── app_frontend
│   │   │   └── __init__.py
│   │   ├── __init__.py
│   │   ├── run_admin.py
│   │   ├── run_both.py
│   │   ├── run_frontend.py
│   │   ├── wsgi_admin.py
│   │   ├── wsgi_both.py
│   │   └── wsgi_frontend.py

There are two apps, frontend and admin. The frontend app is in the directory app_frontend. It consists of only one file __init__.py:

# app_frontend/__init__.py

from flask import Flask, request

def create_app():
    app_name = 'frontend'
    print('app_name = {}'.format(app_name))

    # create app
    app = Flask(__name__, instance_relative_config=True)

    @app.route("/")
    def hello():
        return 'Hello ' + app_name + '! request.url = ' + request.url
    
    # return app
    return app

The admin app is in the directory app_admin. It is almost identical to the frontend app. In both apps I hard-coded the application name to make sure we really see the right app:  

# app_admin/__init__.py

from flask import Flask, request

def create_app():
    app_name = 'admin'
    print('app_name = {}'.format(app_name))

    # create app
    app = Flask(__name__, instance_relative_config=True)

    @app.route("/")
    def hello():
        return 'Hello ' + app_name + '! request.url = ' + request.url
    
    # return app
    return app

Run the two apps with Flask's development server

To check they can run I created two files, run_frontend.py and run_admin.py:

# run_frontend.py

# import frontend
from app_frontend import create_app as app_frontend_create_app
frontend = app_frontend_create_app()

if __name__ == '__main__':
    frontend.run(host='0.0.0.0')
# run_admin.py

# import admin
from app_admin import create_app as app_admin_create_app
admin = app_admin_create_app()

if __name__ == '__main__':
    admin.run(host='0.0.0.0')

In the project directory, type the following command:

python3 run_frontend.py

This will start Flask's developement server:

app_name = frontend
 * Serving Flask app "app_frontend" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)

Then point your browser to: 

http://127.0.0.1:5000/

and you should see the following text:

Hello frontend! request.url = http://127.0.0.1:5000/

To check the admin, type in the project directory: 

python3 run_admin.py

and point your browser to: 

http://127.0.0.1:5000/

and you should see:

Hello admin! request.url = http://127.0.0.1:5000/

This was not really special, we now have two working apps.

Run the two apps with the Gunicorn WSGI server

To be able to run the two with the Gunicorn server I created again two files:

# wsgi_frontend.py

from run_frontend import frontend
# wsgi_admin.py

from run_admin import admin

Now we run the Gunicorn server. I do not go into all details here, you may want to read about Gunicorn's configuration options, including the working directory/Python path. The most important thing here is that we must start Gunicorn using the absolute path

/var/www/.../flask_dispatch/bin/gunicorn -b :5000 wsgi_frontend:frontend

The terminal should show:

[2019-10-09 11:07:31 +0200] [28073] [INFO] Starting gunicorn 19.9.0
[2019-10-09 11:07:31 +0200] [28073] [INFO] Listening at: http://0.0.0.0:5000 (28073)
[2019-10-09 11:07:31 +0200] [28073] [INFO] Using worker: sync
[2019-10-09 11:07:31 +0200] [28076] [INFO] Booting worker with pid: 28076
app_name = frontend

Point your browser to: 

http://127.0.0.1:5000/

and you should see:

Hello frontend! request.url = http://127.0.0.1:5000/

 You can do the same for the admin. Nothing special, we now have two apps that both can be served by Gunicorn.

Application Dispatching with Flask's development server

Using Werkzeug's DispatcherMiddleware it is very easy to combine both apps into one that can be served by the gunicorn WSGI HTTP server. This is described in the Flask document Application Dispatching, see references below. Note that DispatcherMiddleware was moved from werkzeug.wsgi to werkzeug.middleware.dispatcher as of Werkzeug 0.15. Again we want to test this first using Flask's development server. For this I created a file run_both.py:

# run_both.py

from werkzeug.middleware.dispatcher import DispatcherMiddleware
from werkzeug.serving import run_simple

# import frontend
from app_frontend import create_app as app_frontend_create_app
frontend = app_frontend_create_app()

# import admin
from app_admin import create_app as app_admin_create_app
admin = app_admin_create_app()

# merge
application = DispatcherMiddleware(
    frontend, {
    '/admin': admin
})

if __name__ == '__main__':
    run_simple(
        hostname='localhost',
        port=5000,
        application=application,
        use_reloader=True,
        use_debugger=True,
        use_evalex=True)

The DispatcherMiddleware object does not have a method 'run'. Instead we can use 'run_simple'. 

After starting Flask's development server:

python3 run_both.py

you should see this:

app_name = frontend
app_name = admin
 * Running on http://localhost:5000/ (Press CTRL+C to quit)
 * Restarting with stat
app_name = frontend
app_name = admin
 * Debugger is active!
 * Debugger PIN: 136-162-082

Pointing our browser to:

http://127.0.0.1:5000/

we see:

Hello frontend! request.url = http://127.0.0.1:5000/

And when we point our browser to:

http://127.0.0.1:5000/admin/

we see:

Hello admin! request.url = http://127.0.0.1:5000/admin/

Great, we have both apps running on a single domain, 127.0.0.1, and the prefix dispatches the request to either the frontend app or the admin app. 

Application Dispatching with the Gunicorn server

To run both applications with the Gunicorn server I created the file wsgi_both.py:

# wsgi_both.py

from run_both import application

After starting the Gunicorn server:

/var/www/.../flask_dispatch/bin/gunicorn -b :5000 wsgi_both:application

the terminal shows:

[2019-10-09 11:17:25 +0200] [28508] [INFO] Starting gunicorn 19.9.0
[2019-10-09 11:17:25 +0200] [28508] [INFO] Listening at: http://0.0.0.0:5000 (28508)
[2019-10-09 11:17:25 +0200] [28508] [INFO] Using worker: sync
[2019-10-09 11:17:25 +0200] [28511] [INFO] Booting worker with pid: 28511
app_name = frontend
app_name = admin

Now again, pointing the browser to:

http://127.0.0.1:5000/

shows:

Hello frontend! request.url = http://127.0.0.1:5000/

and pointing the browser to:

http://127.0.0.1:5000/admin/

shows:

Hello admin! request.url = http://127.0.0.1:5000/admin/

script_root and paths

It is important to understand that when the admin url is called by the dispatcher, the script_root (request.script_root) of the application changes from empty to '/admin'. Also the path (request.path) does not include '/admin'.

(See link below: ) 'The path is the path within your application, which routing is performed on. The script_root is outside your application, but is handled by url_for.'

Because usually we only use url_for() for url generation there will be no problem. However, if you are using using a Flask url path, like request.path, current_app.static_url_path, in your application, you must prepend this with the script_root. An example of using the path in a template, before: 

    {{ request.path }}

after:

    {{ request.script_root + request.path }}

Unless you know what you are doing, try to avoid using the Flask url path directly in code and use url_for().

Sharing static items

Blog posts can have one or more images. The frontend serves the images from its static folder. The admin contains functions to upload an image and to assign an image to a blog post. To keep things simple I choose to move the static folder to the folder where the app_frontend and app_admin folders are so it not only is shared but also looks shared.  

The only thing we need to change to make this work is pass the static_folder when the Flask object is created: 

    app = Flask(__name__, 
        instance_relative_config=True,
        static_folder='/home/flask/project/shared/static')

This is only done for development. 

Sharing constants and data model

You should never duplicate code. The constants and data model are among the first things we share between frontend and admin. We put the app_constants.py and models.py in the shared directory. After that we replace the references to them in the application files:  

from shared.app_constants import *
from shared.models import <classes to import>

Sharing Blueprints

A number of Blueprints can be shared between frontend and admin. One is the auth Blueprint used to log in and log out. Another one is the pages Blueprint that shows pages. Sharing Blueprints is easy, we just create a 'shared/blueprints' directory and put the blueprints here. In the frontend and admin's create_app() function in __init__.py we change: 

    from .blueprints.auth.views import auth_blueprint
    app.register_blueprint(auth_blueprint, url_prefix='/<lang_code>')

to:

    from shared.blueprints.auth.views import auth_blueprint
    app.register_blueprint(auth_blueprint, url_prefix='/<lang_code>')

The view functions in the blueprints call render_template which means we must make sure that the templates exist in both the the frontend and admin. Later we may also change the template directory for these shared blueprints.   

Logger problems

I use the logger both in the frontend app and in the admin app, the frontend app logs to a file app_frontend.log and the admin app logs to a file app_admin.log.

After using DispatcherMiddleware it appeared that a any log message was always written to both log files, messages from the frontend app were written to app_frontend.log and app_admin.log, and messages from the admin app were written to app_frontend.log and app_admin.log. 

It appears this has to do with the fact that app.logger always has the name flask.app. Although there are ways around this, it is better to upgrade Flask to 1.1 (or 1.1.1) where app.logger now takes the same name as app.name. After the upgrade logging was separate for the frontend and admin.

Static directory for staging and production 

I am using Gunicorn with a Nginx reverse proxy. For the pages this works fine but the static directory did not get mapped properly. Images were not shown when in admin mode, i.e. on the url '/admin'. I do not directly know another way then to add another location directive to Nginx compensating the /admin. So before:

  location /static/ {
    alias /var/www/.../static/;
  }

And after using DispatcherMiddleware:

  location /admin/static/ {
    alias /var/www/.../static/;
  }

  location /static/ {
    alias /var/www/.../static/;
  }

Summary

Using Werkzeug's DispatcherMiddleware makes it easy to have two apps running on one domain. For development it is probably a good idea to use two Flask development servers, one for the frontend app and one for the admin app. 

First steps implementing this in my application gave few things that needed to be fixed. What I did was move the app folder to app_frontend and copy app_frontend to app_admin. In development mode in had to change hostname='localhost' to hostname='0.0.0.0', but this is an issue when running with docker. Next I had to change the session cookie names to avoid conflicts, remove '/admin' from the url_prefix in the register_blueprint function, rename the log files, and change the location of the models, 'from app import models' to 'from app_frontend import models'. Many changes can be made by using recursive regex replacement, see 'regexxer' for Linux/Ubuntu.   

Then it was running. Are two apps running on the same domain and written by yourself, always fully separated? I don't think so. I introduced a shared folder, where we put the static folder. The shared folder is also used to share the data model and some code between frontend and admin.  

Time to remove admin code from the frontend and frontend code from the admin and share what must be shared! 

Links / credits

Add a prefix to all Flask routes
https://stackoverflow.com/questions/18967441/add-a-prefix-to-all-flask-routes

Application Dispatching
https://flask.palletsprojects.com/en/1.1.x/patterns/appdispatch/

DispatcherMiddleware with different loggers per app in flask 1.0 #2866
https://github.com/pallets/flask/issues/2866

Flask 1.1 Released
https://palletsprojects.com/blog/flask-1-1-released/

How do I run multiple python apps in 1 command line under 1 WSGI app?
https://www.slideshare.net/onceuponatimeforever/how-do-i-run-multiple-python-apps-in-1-command-line-under-1-wsgi-app

How to implement Flask Application Dispatching by Path with WSGI?
https://stackoverflow.com/questions/30906489/how-to-implement-flask-application-dispatching-by-path-with-wsgi

request.path doesn't include request.script_root when running under a subdirectory #3032
https://github.com/pallets/flask/issues/3032

Serving WSGI Applications
https://werkzeug.palletsprojects.com/en/0.15.x/serving/