This is the text version of my EuroPython talk, Deploying Python Web Apps in 2026, which I recently presented in Krakow, Poland.

I will add the video version when it it is ready in a few weeks. The EuroPython team traditionally does an amazing job making the talks available shortly after the conference.

Deploying Web Apps in 2026

Slide 1

Hi everyone, Thank you for coming. I’m going to talk to you today about deployment. I’m a Developer Advocate at PyCharm/JetBrains and have been involved in the Python and Django communities for a while.

Slide 2

We ask a lot of modern web developers. Be an expert programmer in multiple programming languages: Python, JavaScript, TypeScript, and increasingly be dangerous with Rust, too. Know HTML and CSS. The former is learnable quickly, but CSS can become complicated fast and talented programmers and developers can spend an entire career on just it. That’s why there are CSS frameworks out there like Tailwind, Bootstrap, and others, but that’s yet another level of complexity we are expected to manage.

Web developers should also understand databases, write performant SQL, and know how to manage databases so they’re secure and not slow. It’s also important to implement user authentication so people can use your site, but don’t mess up the security here–keep out the bad actors! And so on. It’s kinda a lot.

Deployment fits into this bucket list of skills for web developers, too. Not our core job, but critical. Not something we implement or even focus on all the time, but a tool we use everyday, since most people are deploying from local to production regularly.

Slide 3

If you look for advice online, it seems everyone does deployment differently. There are few best practices–everyone has their favorite PaaS or VPS or custom approach. Even an LLM can’t make up its mind.

Slide 4

This slide is just a short list of available providers out there. You might have a choice on which one to use for work, or maybe not.

Slide 5

If you look closely there are different categories of hosting options within this collage.

  • Hyperscalers like Google, Azure, and AWS give you a catalog of services: hosting, databases, media, queues, etc.
  • VPS (Virtual Private Server) options sit slightly down the stack, offering you a virtual private server with as much size as you want.
  • Self-hosted PaaS (Platforms as a Service) options are a hybrid category that is growing, whereby you can operate your own PaaS on your VPS of choice.
  • Traditional PaaS options that handle most of this for you, for a fee, are there
  • Framework-specific hosting options, most notably FastAPI cloud, are the final option

The general point is that there are a lot of options along the spectrum. The question is how much do you want to do yourself.

Slide 6

But here’s the thing: 90% of Python app deployments are the same. (This is definitely not a made up number). Without a mental model of the entire process you will be entirely lost. But almost all of it is independent of a specific provider. You just need to know where to focus. Otherwise you’ll be blindly following online tutorials or AI text, following a checklist you don’t understand leading to an outcome you can’t define.

I’ve been there. I deployed dozens and dozens of websites without, to be honest, a clear mental model of what I was doing.

Slide 7

My goal is to get you to this. Some feeling of peace and zen. Knowing how the big pieces of deployment work so that you can zoom in and out as needed.

We’re not infrastructure engineers whose job is 24/7 to do deployments and manage up time. Most of us are simple web developers, but we are forced to act as infrastructure engineers, too.

In the real world, you need a mental model of deployment because otherwise you’re hunting for online tutorials, or asking AI for tips, and just blindly following a checklist you don’t understand.

This talk is the one I wanted to see when learning how to be a web developer. Overwhelmed by all these new tools. And lacking a mental model of how the pieces fit together. A lot of this is web app deployment 101 type of information that applies to any programming language or framework. But as you’ll see, there are some Python-specific things developed over the years to make things even nicer for us in the Python community.

So whether you have never deployed a website before, have done it in one framework only, just used a PaaS and not a VPS, I think you will take something away from this presentation.

Slide 8

We all know this is Steve Jobs, right? There is an apocryphal tale of how the Mac’s drag and drop functionality for CD burning came to be. The short version is Apple engineers and designers and marketers spent months on this new feature, leading up to a big meeting with Jobs. They’re eager to present their research, implementation plans, all of it. They’re all seated around a table. Jobs walks in, goes the whiteboard, and draws a music folder with an arrow to a CD.

That’s it. That’s the functionality. Do it. So this tale goes.

Slide 9

Deployment is NOT this simple unfortunately. But we will use this story as a guiding metaphor for the talk. What we want is to just drag our local website onto a server somewhere and be done with it. Things aren’t quite that simple, but we can get close.

The problem is that there is quite a lot going on locally in our beloved frameworks of choice to make a seamless local development environment experience happen.

Slide 10

In fact, a lot of what is happening locally feels like magic at first. I know we all hate magic as developers–we want things explicit, especially as Python developers–but software is built on abstractions so some degree is good. And you can go in and fiddle with these things if you want, but then you are largely fighting the framework, which is bad.

I have a list of things that differ and my goal is that there’s nothing magical about all this at the end. In fact, maybe you’ll go up to some of the maintainers–I think Sebastian from FastAPI is here–and thank them for doing all this work for us.

We’re going to build up the complexity step-by-step (10 total) so you can see what’s involved step-by-step. Remember, these 10 steps are “all” that’s happening in any Python web deployment. So it’s worth internalizing them.

Slide 11

Step 1. There’s a lot going on locally to serve you a Python site–and the key thing to notice is two different jobs are happening, even though locally it only looks like one.

Locally, a single process does everything. For Django that’s runserver, for Flask flask run, and for FastAPI fastapi dev. You type one command that one server accepts the connection, parses the HTTP, runs your code, and even hands back your static files (more on that soon). One command, one process. It feels seamless and that’s exactly the point. It’s hiding a split.

In production, those two jobs come apart into two separate servers. The first job is the web server–nginx or Caddy. Think of it as the front door. It speaks HTTP to the browser, handles TLS, and serves static files directly. It’s built to juggle thousands of connections fast, and it doesn’t run a single line of your Python web app code.

The second job is the app server–Gunicorn or Uvicorn. This is the one that actually runs your code: Django, Flask, FastAPI. The web server takes every request at the door and hands the dynamic ones off to the app server, which calls your application and passes a response back.

This is the first thing installed purely for production. Locally you never need it; the dev server was your app server. It’s the first of a small handful of production-only packages, and we’ll pick up more as we go.

And that handoff–the line between the two boxes–is WSGI or ASGI: a standard contract for how a web server talks to a Python app server, so any of them can mix and match. WSGI is the original synchronous one; ASGI is the newer async version for things like websockets.

So: one process locally, two servers in production.

A brief history lesson

PEP 333 from 2003 is for WSGI (Web Server Gateway Interface), which standardized how this works “for Python apps.” If you’re not using Python, you’re on your own. Or rather need to use something else.

This was later updated to PEP 3333 (the Python 3 update) in 2010. And ASGI (Asynchronous Server Gateway Interface) emerged in the mid 2010s from Django Channels.

Django ships with both ASGI/WSGI. FastAPI is built on Starlette: there is no WSGI mode; it’s async all the way up. Flask is WSGI-only, but Quart is Flask with ASGI.

Slide 12

Step 2: Here’s the surprising part: between local and production your code doesn’t really change. The same files roughly ship to both. What changes is the configuration fed into them–and that’s this new dashed box, the environment.

It wasn’t always this way. The old approach was separate settings files, one for local, one for staging, one for production, and you’d swap them depending on where you were. The problem is your secrets end up sitting in your codebase. The modern best practice is to pull all that out into environment variables, read in at startup.

So what actually lives here? The database URL. The secret key. Whether DEBUG is on or off in a Django app. API keys for email, payments. The handful of values that differ between your laptop and the real thing–the ones you never want committed to git.

This comes straight from the twelve-factor app, written by Heroku about fifteen years ago, and it does two things at once: it keeps secrets out of your code and it lets the exact same code behave differently in each environment just by changing what you feed it. Locally you keep a .env file that you gitignore; in production you set the real values on the platform.

And here’s why it comes so early, as Step 2: every box we add for the rest of the talk wires in the same way–a URL or a key, sitting in the environment, read once at startup. The code stays the same. We’re just going to keep handling it more connections.

Slide 13

Step 3: The database. Locally, you’re almost certainly on SQLite–it’s Django’s default, and the usual starting point for SQLAlchemy or SQLModel. And for good reason: it’s just a file, fast, and there’s no separate server to install or run. You get a real database for free.

But this is the first box where what’s perfect locally is often the wrong tool in production–and it’s worth being precise about why. SQLite isn’t a toy; it’s the most deployed database on Earth. In every cell phone, appliance, car, and so on. The catch is it’s almost entirely embedded–one file, one user, one device. Web production is the opposite: many users writing at the same time, which is historically where SQLite struggles.

There’s a real SQLite-on-the-web movement now, but it mostly solves replication and read performance. The single-writer limit is still there. I’ll leave the rest for the hallway track.

So the production default is a server-based database, Postgres is particularly popular, that runs as its own independent service. Your app connects to it over the network. A PaaS will often hand you one and wire it up; otherwise you point at a managed database, or run your own.

You can run a server-based database like Postgres locally–it’s not too hard, especially with Docker containers–but that’s a more advanced local set up. Most developers start, at least, with SQLite.

If you’re keeping track, this is the second production-only package we need: the Postgres driver, psycopg.

Here’s the first payoff of that environment box from a minute ago: the PaaS hands you the database as a single value–a DATABASE_URL–and a line or two of config points your framework at it.

One gotcha I see all the time newcomers deploy a new site and then go: where’s the info? Because they have added, say, blog posts, and created users locally. But in production it’s a different database. Seems simple, but I bet everyone does this a few times when learning web development.

Slide 14

Step 4. Static files. If you’ve deployed before, you probably just groaned a little–this is the box that bites everyone. So what are they? Everything that isn’t code and doesn’t change per user: your CSS, your JavaScript, images, fonts. The files that just ship alongside your app.

Locally, you have never thought about these, and that’s the tell — it’s the clearest example yet of the dev server quietly doing a job for you. Django and Flask serve your static folder automatically; you drop a file in, it shows up. (FastAPI makes you mount it explicitly, but FastAPI apps are usually JSON APIs, so there the static problem moves into a JavaScript bundler instead.)

The rite of passage here is a Django one: you set DEBUG=False like the docs say, reload, and your CSS is gone–because Django stops serving static once debug is off. But the underlying lesson is universal: in production, the dev server’s free static-serving goes away in every framework. Flask won’t serve it at scale, FastAPI never offered to. Somebody else has to serve those bytes now.

In production, there are three options, from simplest to beefiest:

  1. WhiteNoise–another of those production-only installs–where your Python process serves the files itself, properly, with compression and cache headers and no extra infrastructure, which is why it’s the PaaS default.
  2. the web server, nginx or Caddy serving them directly, which is what’s drawn on the diagram
  3. a CDN (Content Delivery Network), once you have real traffic and want those files sitting close to your users.

Two things to bank before we move on. First, there’s a step hiding in here: in Django, a command called collectstatic gathers every static file into one place so it can be served — and that runs in what’s called the build step. Hold onto that phrase; it’s the second thing we’ll cash in at the very end. Second, a quick distinction people miss: files a user uploads–avatars, attachments–are media files, not static files, and those belong in off-server object storage like S3, never on the server’s own disk.

Slide 15

Step 5. Still with me? There’s only 10 of these. That’s the talk. So hang in there. I know this information is a little dense. I’ll post the slides online later, so you can refer to them as needed.

Step 5 is Authentication–how users sign up and login. Locally, this is a solved problem, because you can just fake it: spin up a user straight from the terminal (createsuperuser in Django) and you’re in. In production it suddenly gets real, and getting it wrong is the kind of mistake that ends up in the news. (You can dodge a lot of this with social login–”login with Google”–but assume you’re rolling your own.)

Here’s the part that sneaks up on people. Say you let users create their own accounts without email confirmation. Fine. But even if you YOLO that, what about password reset? Now you’re sending mail. Authentication, it turns out, secretly depends on email–and that’s the box on the diagram.

Yes, I know you don’t have to use email. Could do SMS, 2FA, passkey, other options. But take this box as a proxy for some service to power authentication.

Continuing with our email example, you’ll need a transactional email service like Postmark, SES, SendGrid, Mailgun, and then you have to prove to the receiving inboxes that you’re actually you, by setting SPF and DKIM records on your domain. None of this is Python, but all of it has to be in place before a single password-reset-link reaches anyone.

The good news, if there is one, is that the environment box pops up again. The email service is another API key sitting in your config, read at startup.

Also a couple of things change the instant your auth is public. Your login form is now on the open internet, which means bots will find it and hammer it, so you want rate limiting on your auth endpoints.

But notice that we’re quietly assuming the login session itself is trustworthy. That little cookie traveling back and forth between the browser and your server can’t be read or forged. And that assumption is exactly where we go next.

Slide 16

Step 6. Security. And notice something different about this slide: no new box. For the first time we’re not adding a diagram, we’re hardening the lines already on it. Security in deployment isn’t a thing you install; it’s defending the connections between boxes you already have.

Locally, none of those lines need defending–the only person on your network is you. In production, every one of them is out on the public internet, where anyone can reach them.

So first, everything is HTTPS, which is just HTTP wrapped in TLS encryption, and the good news is your platform almost certainly handles the certificates for you automatically. But HTTPS alone isn’t enough, because it doesn’t specifically protect the session cookie–and that cookie is the thing that remembers who you are, since HTTP itself has no memory. So you harden the cookie directly: The Secure flag, so the browser never sends it over plain HTTP; HttpOnly so JavaScript can’t read it; and HSTS so the browser refuses to speak plain HTTP at all.

Then there’s the code side. In Django, there’s a DEBUG=False config that stops showing stacktraces on error pages, forces a proper SECRET_KEY, forces updating ALLOWED_HOSTS, and there’s a genuinely useful command most people don’t know about–manage.py check --deploy–that audits most of this for you and tells you what you missed.

The other two frameworks have their own version of this checklist. Flask leans on the Flask-Talisman extension to force HTTPS and set security headers. FastAPI often sidesteps the session-cookie question entirely, because it’s usually an API handing out tokens instead–and when it is the backend for, say, a Next.js frontend, the cookie lives on the frontend server, so the lock travels with it there.

That’s the request hardened. But hardening doesn’t make anything faster–and some of the work we cram into a request is simply too slow to belong there. Fixing things brings new boxes back to the diagram.

Slide 17

Step 7. Background workers. You’ve never needed these locally, for a simple reason: when something’s slow on your laptop, you just… wait. Nobody’s timing you. In production, a web request has a budget measured in milliseconds — and some of the work we ask it to do blows right through that.

What kind of work? Sending a few thousand emails. Resizing the photo someone just uploaded. The cleanup after a Stripe charge — the charge itself is quick, but the webhooks, receipts, and reconciliation that follow are not. Or, increasingly, an LLM call that needs ten seconds to think. None of these belong inside a request, with a user watching a spinner while they run.

The fix is a task queue: hand the slow job off, return a response immediately, and let the work happen in the background. This is common enough that it’s becoming built-in — Django 6.0 ships a Tasks interface for exactly this, and FastAPI has BackgroundTasks for smaller jobs — but the shape underneath is worth seeing, because it’s two new boxes.

The first is the message broker — usually Redis. Think of it as a mailbox: a fast little datastore whose only job is holding the list of work waiting to be done. Your app drops a note in and moves on; it doesn’t wait around for the result.

The second is the task worker — and here’s the nice part: it’s a second copy of your application. The same code you’ve been deploying all along, except with no web server bolted to the front of it. Remember the app server from Step 1? This is that same Python, running on its own, pulling notes out of the mailbox and doing the work. Celery and RQ are the usual tools.

And notice the wiring: your web process never talks to the worker directly — everything goes through the broker. That decoupling is the entire point. Either side can crash, restart, or scale up without the other even noticing. (It also means that when a worker fails, there’s no user sitting there to complain about it — hold onto that, because it comes back at the monitoring box.)

So we’ve moved the slow work out of the request. The next question is what makes the work that’s left slow — and the answer is almost always one box we already have. That Redis mailbox, by the way, is about to do more than hold mail.

Slide 18

Step 8. Performance. And like security, this slide adds no new boxes–just problems to solve on the boxes we already have. So where do production apps actually get slow? Here’s the thing: it’s almost never Python, and it’s almost never your framework. It’s almost always the database. You’ve got three moves, and the order matters.

First, ask better questions. The classic is the N+1 problem–invisible locally, brutal in production. Picture a homepage listing a hundred blog posts, each showing its author. Your ORM runs one query for the posts… and then, as the template renders each one and reaches for its author, one more query per post. That’s a hundred and one queries for a page that needed two. The fix is telling the ORM to fetch it all up front — select_related and prefetch_related in Django, joined loads in SQLAlchemy.

Second, help the database answer faster–that’s indexes. Django already indexes your foreign keys for you; the rest you add once production shows you which queries actually matter. And don’t reflexively index everything: every index you add is one more thing to update on every single write.

Third, caching. Compute the answer once, park it in Redis, serve it from memory. It’s the most powerful move and the most dangerous, because now you have two copies of the truth and you have to know when to throw the stale one away. That’s why it’s last: reach for it after you’ve fixed the queries, not instead of fixing them.

Now, two quiet promotions on the diagram. First, that Redis box–it arrived as your message broker, but it’s just as happy holding your cache, and your session store too. One service, three jobs, one REDIS_URL. In practice you’ll usually split this into separate Redis databases or instances once you have real cache traffic. Second, the app server: you scale it by running more copies–more workers, more machines, sitting behind a load balancer. On a PaaS, that’s often a literal slider you drag.

And here’s the question worth ending on: why does that slider work? Why can you scale your most important box just by dragging a dial? Because nothing important lives inside it anymore. Your sessions are in Redis. Your data is in Postgres. Your uploads are in object storage. Your slow jobs are in the queue. We have spent this entire talk quietly carrying state out of that purple box–and that’s the whole reason scaling it is a dial, and not a project.

Slide 19

Step 9. Monitoring. Anything that can break will, but how do you know? There’s many moving pieces in production that can go wrong, not to mention users will eventually find all the pathways that break. Will you be notified when that happens? That’s what this box fills. Notice it connects with dashed lines, not solid arrows, because it’s not part of the request path. It sits off to the side and watches.

Think about how this is different from local. On your laptop, when something breaks you know instantly–the traceback is right there in your terminal, or you see a yellow error page in the case of Django. In production, if you see it at all, it’s just a polite blank 500 because we hid the details on purpose, for security.

This monitoring box does a few things. A tool like Sentry (or DataDog, New Relic, GlitchTip) catches every exception, tells you which pages are slow.

Whatever monitoring tool you use is the last of those production-only packages we started collecting back in Step 1: Gunicorn for a WSGI server, psycopg for the Postgres, database driver, WhiteNoise for static files, and this. Like every single box before it, the wiring can go through Environment variables.

There’s only one thing remaining…

Slide 20

Step 10, the final one, is the deploy itself. Locally, deploying is just hitting save: the dev server reloads, your change is live. In production, deploying is this strip along the bottom. 4 steps from left to right.

  • You git push the code.
  • Then tests. There are talks and entire books on testing, so I’m going to largely wave my hands here, but you want automated tests, especially in production, to run before allowing any changes.
  • Then build, where your source code becomes a runnable artifact, usually a container image, whether you wrote the Dockerfile yourself or a buildpack inferred one for you. This is where collectstatic for static files runs, in Django anyway, if you remember way back to step 4.
  • The release: before your new code serves a single request, the database schema has to match it. So your migrations run here, as a deliberate release command.

And with that last arrow… we’re done. This is the entire talk, on one slide. Every box up there is something your dev server was quietly doing for you, or quietly hiding from you, on your laptop. The gap–the distance between one process running on localhost and this whole diagram–is what the word “deployment” actually means.

The good news is you now have the map. A decent PaaS will handle most of these boxes already assembled and wired together. Or you can do it yourself if you need to.

Slide 21

We’re back to where we started and back to the question: why can’t we just drag and drop a website like burning a CD?

I’ve given you 10 reasons why not and there are many more once you start going down the infrastructure rabbit hole.

I hope this overview is helpful in demystifying what’s happening in deployment. Because if you have this mental model, then diving into whatever deployment checklist you come up with for yourself or use at your company, should make a lot more sense.

Slide 22

Thank you for your attention.