HTTPS redirection with Sanic

This question was recently asked by @LordOdin on the Discord server.

How can I make an http and https server in sanic with an auto https redirect

I am providing an answer here because:

  1. It will be easier to follow, and
  2. it will be easier for someone else to find in the future.

Of course, reverse proxying in NGINX is a solution. There are loads of tutorials online how to do that. But, how can we do it in Sanic? :thinking:

First, we need to think about what needs to happen. HTTP requests are typically served by default on port 80. And, a request using TLS encryption over HTTPS typically uses 443.

So our solution must accomplish responding over two different ports whereby one port is redirecting towards the other.

Let’s get the easy step out of the way and setup two apps. I am using 9999 and 8888 as my ports, but there is nothing special about them. Swap in 443, and add SSL to the HTTPS app, and it will operate the same.

HTTP_PORT = 9999
HTTPS_PORT = 8888

http = Sanic("http")
http.config.SERVER_NAME = f"localhost:{HTTP_PORT}"

https = Sanic("https")
https.config.SERVER_NAME = f"localhost:{HTTPS_PORT}"

Now, let’s setup some routes the ones that we care about serving on HTTPS.

@https.get("/foo")
def foo(request):
    return text("foo")


@https.get("/bar")
def bar(request):
    return text("bar")

Our HTTP app also needs a route. It will only have one, and its job is to catch any request and redirect it. We will use sanic.response.redirect to handle that.

In this example, we use app.url_for() to rebuild the URL, and we explicitly tell it that we want it to point to the SERVER_NAME of our other server. Also important is setting the _scheme. In my simple example where I am not actually using encryption, I left it as _scheme="http", but to implement this for real, you would switch that to https.

@http.get("/<path:path>")
def proxy(request, path):
    url = request.app.url_for(
        "proxy",
        path=path,
        _server=https.config.SERVER_NAME,
        _external=True,
        _scheme="http",
    )
    return response.redirect(url)

The last part comes in wiring it up. I am going to use two brand new listeners that will be available in v21.3, but you could use listener("before_server_start") and listener("after_server_end") as well.

Basically, we will use the create_server pattern and store the alternative server instance on our main HTTPS app. When it starts up, it will also start the secondary app, and take care of its lifecycle as well.

@https.listener("main_process_start")
async def start(app, _):
    global http
    app.http_server = await http.create_server(
        port=HTTP_PORT, return_asyncio_server=True
    )
    app.http_server.after_start()


@https.listener("main_process_stop")
async def stop(app, _):
    app.http_server.before_stop()
    await app.http_server.close()
    app.http_server.after_stop()

Putting it all together:

from sanic import Sanic, response, text


HTTP_PORT = 9999
HTTPS_PORT = 8888

http = Sanic("http")
http.config.SERVER_NAME = f"localhost:{HTTP_PORT}"
https = Sanic("https")
https.config.SERVER_NAME = f"localhost:{HTTPS_PORT}"


@https.get("/foo")
def foo(request):
    return text("foo")


@https.get("/bar")
def bar(request):
    return text("bar")


@http.get("/<path:path>")
def proxy(request, path):
    url = request.app.url_for(
        "proxy",
        path=path,
        _server=https.config.SERVER_NAME,
        _external=True,
        _scheme="http",
    )
    return response.redirect(url)


@https.listener("main_process_start")
async def start(app, _):
    global http
    app.http_server = await http.create_server(
        port=HTTP_PORT, return_asyncio_server=True
    )
    app.http_server.after_start()


@https.listener("main_process_stop")
async def stop(app, _):
    app.http_server.before_stop()
    await app.http_server.close()
    app.http_server.after_stop()


https.run(port=HTTPS_PORT, debug=True)
2 Likes

let me benefited a lot :cowboy_hat_face:

1 Like

This is a great example of a pattern that @ashleysommer and I were discussing recently about the flexibility of having a blueprint attached to multiple applications. You could imagine, for example, wanting functionality like middleware or specific routes that would be available in both locations.

I have not tested this specifically. But, the new Signals API should also be able to handle this.

Yep, I’ve used blueprints attached to multiple sanic Apps in the past in a similar way.

And just FYI, all Sanic Plugins that are built upon the Sanic Plugins Framework are designed to be applied to multiple apps simultaneously too, because they are essentially big fancy Blueprints themselves.

And a very minor tangent:
Back in March 2017, I wrote a library called Sanic-Dispatcher, based loosely on the Werkzeug Application Dispatcher Middleware library.
https://github.com/ashleysommer/sanic-dispatcher

It allows you to run multiple Sanic apps, and even Flask Apps and Django Apps all in the same project, you can register them all into your Dispatcher with different prefix paths. The dispatcher receives all incoming requests and passes it onto whichever App needs it based on the URL prefix. It translates the Sanic request to a WSGI request for Flask and Django apps. The dispatcher is a sanic app itself, so you can put routes and middlewares at the dispatcher level too for doing things like CORS responses, or gzip response compression, at a higher level.

I built it because I had a very specific need to run a Sanic App and a Flask App side by side, but I found it useful for some other things too. Anyway, I haven’t used it since 2019, an don’t know if it even works on newer Sanic versions.

1 Like

If you use Sanic as an ASGI application you can use Hypercorn to achieve this as well. These are the docs; in summary you serve using hypercorn --certfile cert.pem --keyfile key.pem --bind localhost:443 --insecure-bind localhost:80 module:app and wrap HTTPToHTTPSRedirectMiddleware around the sanic app.

Awesome @pgjones, and thanks for your work on hypercorn.

I have got this working with Sanic 21.6.1, but if I update to 21.9 I get errors from ‘start’:

sanic.exceptions.SanicException: Cannot dispatch server event without first running server.startup()

I am new to Sanic so I haven’t a clear idea yet of what has changed.
Is there anything simple to be done to have this work with the latest release?

@grahamrjuk You will need to call the following on the redirection app:

redirect_app.signalize()
redirect_app.finalize()

In a few days with the v21.12 release there will be better instructions on the main site, and we are working on a simple method for doing this out of the box with minimal configuration in v22.

Thanks for your help - I will look out for v21.12 and later as well.

1 Like