Deploy a Flask App: A Practical First-Deployment Walkthrough
You have a Flask app that runs on your laptop. You start it, open the browser, click around, and it works. The next step is the one that feels much bigger than it actually is: putting that app on the public internet so other people can reach it. This guide is the practical walkthrough for doing exactly that.
The conceptual half of the story, what really changes when an app leaves your machine, lives in our companion guide, Local Hosting vs Deployment. That guide explains why each of the steps below exists. This one is the hands-on version: the files you add, the commands you run, and the order you run them in to get a small Flask app live on a managed platform.
The flow is deliberately platform-neutral. Render, Railway, and Fly.io differ in their dashboards and their pricing, but the shape of a first deployment is the same on all of them. We will name them as examples and point you at their current docs for the exact clicks, because those details change and the underlying steps do not.
The app we are deploying
To keep every step concrete, we will deploy the smallest useful Flask app: one file, one route, returning a little JSON. If you already have an app, the same steps apply to it.
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/")
def home():
return jsonify(message="Hello from my deployed Flask app")
if __name__ == "__main__":
app.run(debug=True)
Run it locally and confirm it works before going any further. The if __name__ == "__main__" block starts Flask's built-in development server, which is perfect for building but, as the next step explains, is not what we want facing real traffic.
Use a production server
Flask ships with a development server, the one you get from app.run() or flask run. It is built for fast feedback while you code, not for serving the public. In production we put a proper WSGI server in front of the app instead, and the common choice for Flask is Gunicorn.
pip install gunicorn
Once it is installed, you can run the same app through Gunicorn locally to prove the setup works before any platform is involved.
gunicorn app:app
That app:app argument trips up a lot of people the first time. It is two halves separated by a colon. The part before the colon is the module, meaning the file app.py without its extension. The part after the colon is the Flask instance inside that file, the object we created with app = Flask(__name__). So app:app reads as "in the module app, find the WSGI application called app and serve it." If your file were named main.py and the instance were called application, the argument would be main:application.
Notice that Gunicorn never touches the if __name__ == "__main__" block. That block only runs when you execute the file directly with Python. Gunicorn imports the module and grabs the instance, so in production the development server is simply never started.
Declare your dependencies
Your laptop has Flask and Gunicorn installed because you ran pip install at some point. The platform's machine is a clean slate. It needs a list of what to install, and for a pip-based project that list is a requirements.txt file in the root of your project.
flask
gunicorn
You can write this file by hand for a small project, or let pip generate it from your current environment, which also captures exact versions.
pip freeze > requirements.txt
When the platform builds your app, it creates a fresh environment and installs exactly what this file lists, nothing more. If a package works on your machine but is missing from requirements.txt, the build will fail with an import error. The file is the contract between your code and the environment it will run in, so anything your app imports belongs in it.
Tell the platform how to start the app
The platform now knows what to install. It also needs to know what to run once everything is installed. That is the start command, and for our app it is the Gunicorn invocation from earlier, with one important addition.
Managed platforms decide for themselves which network port your app should listen on, and they hand that number to your process through an environment variable named PORT. Your start command has to read that variable and bind to it, rather than picking a fixed port of its own.
gunicorn --bind 0.0.0.0:$PORT app:app
The 0.0.0.0 means "listen on all network interfaces," which is what lets traffic from outside the container reach the app. The $PORT is the platform's injected port number. Some platforms ask you to type this command into a dashboard field. Others read it from a Procfile, a single-line file in your project root that names the process type and its command.
web: gunicorn --bind 0.0.0.0:$PORT app:app
Bind to $PORT, not a fixed port
If your start command binds to a hardcoded port, such as a bare gunicorn app:app (which defaults to 8000) or --bind 0.0.0.0:5000, the platform's health check looks for the app on the port it assigned and finds nothing. The signature is a build that succeeds and a process that starts in the logs, followed by a deploy that fails with "no open ports detected" or a health check that times out. The fix is always to bind to 0.0.0.0:$PORT so the app listens where the platform expects.
Push to GitHub
Most managed platforms deploy from a connected Git repository. They watch a branch, and every time you push, they rebuild and redeploy. So the next step is to get your project onto GitHub. Before you commit, add a .gitignore so you do not ship things that should stay local.
.venv/
__pycache__/
.env
The .venv/ and __pycache__/ entries keep your virtual environment and compiled bytecode out of the repository; the platform builds its own. The .env entry is the one that matters most. That file holds your secrets, and committing it pushes API keys and passwords into your Git history where they are very hard to fully remove. Our guide on storing API keys with a .env file covers that pattern in full.
git init
git add .
git commit -m "Initial Flask app ready to deploy"
git branch -M main
git remote add origin https://github.com/your-username/your-repo.git
git push -u origin main
After this push, your code, your requirements.txt, and your Procfile all live in a repository the platform can read. Your secrets do not, which is exactly what we want.
Connect the platform and deploy
Now you connect the repository to a platform and let it do the work. The specifics of where you click vary between Render, Railway, and Fly.io, so follow whichever provider's current documentation you have chosen. The sequence the platform runs through, however, is the same everywhere.
- Connect the repository. You authorise the platform to read your GitHub repo and pick the branch to deploy.
- Install dependencies. The platform creates a fresh environment and installs everything in
requirements.txt. - Run the start command. It launches your app with
gunicorn --bind 0.0.0.0:$PORT app:app, either from the dashboard field or yourProcfile. - Hand you a public URL. Once the app is listening on the assigned port and the health check passes, the platform gives you a URL anyone can open.
When that URL loads your JSON message in a browser, the app is live. From here on, pushing to the connected branch triggers a fresh build and redeploy automatically. The exact dashboard layout, build settings, and any free-tier limits are things to read from the provider directly, since they change and differ between platforms.
Configuration and secrets
A real app needs configuration, things like API keys, a database URL, or a secret key for sessions. Those must not be committed, which is why .env is in your .gitignore. On a platform, the equivalent of your local .env file is the dashboard's environment-variable settings. You add each key and value there, and the platform injects them into your app's environment at runtime, just as your local .env does in development.
Your code reads them the same way in both places, with os.getenv, so nothing in the application has to change between local and deployed. This is the payoff of reading configuration from the environment rather than hardcoding it, the pattern covered in the .env guide.
import os
from flask import Flask, jsonify
app = Flask(__name__)
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")
@app.route("/")
def home():
return jsonify(message="Hello from my deployed Flask app")
Notice what is gone from this version: the app.run(debug=True) block. In production, debug mode must be off, and the surest way to keep it off is to let Gunicorn run the app so that block never executes in the first place.
Never run with debug=True in production
Flask's debug mode enables an interactive in-browser debugger. On a public app, that debugger lets anyone who triggers an error run arbitrary Python on your server, which is a remote-code-execution hole. The signature is a working app that, the moment any route raises an exception, shows a visitor a stack trace with a clickable console. Keep debug off in production, read it from an environment variable if you want it on locally, and let Gunicorn, which ignores the app.run() call entirely, run the app in deployment.
What you just proved
Look back at what actually changed in your code between local and live. Almost nothing. You added a server you do not call directly, a file listing two dependencies, a one-line start command, and you moved your secrets out of the code and into the platform. The route, the logic, the Flask app itself, all the same.
Deployment is the environment, not the code
A first deployment teaches a quietly important lesson: your application barely changes when it goes public. What changes is everything around it. A public machine instead of your laptop, a production server instead of the development one, dependencies installed fresh from a declared list, a start command the platform runs, and configuration read from the environment instead of baked in. Deployment is that surrounding environment, and once you have built it once, every future app follows the same shape.
That is the whole arc of a first deployment. The app proved it could run on your machine; now it has proved it can start in a fresh environment, install its dependencies, bind to the port it is told to, read its configuration from the platform, and answer real requests from the public internet.
Frequently asked questions
Why can't I just run flask run in production?
Because flask run starts Flask's development server, which is built for local building and quick feedback, not for real traffic or stability under load. The Flask documentation explicitly warns against using it in production. Instead, use a production WSGI server such as Gunicorn, which is designed to run your app reliably and to handle real requests. You install it, declare it as a dependency, and point your start command at it.
What is the start command to deploy a Flask app?
The start command is gunicorn app:app, where the first app is the module (your app.py file) and the second is the Flask instance inside it. On a managed platform you also bind to the port the platform assigns through the PORT environment variable, so the full command becomes gunicorn --bind 0.0.0.0:$PORT app:app. Some platforms read this from a Procfile line such as web: gunicorn --bind 0.0.0.0:$PORT app:app.
How do I handle secrets and config in production?
Set them as environment variables in the platform's dashboard, and never commit them to your repository. Keep your .env file out of Git with a .gitignore entry. Your code reads the values with os.getenv, which works the same locally and in production, so nothing in the app has to change. And always turn debug mode off in production, because an exposed debugger lets visitors run code on your server.
Mastering APIs with Python
A first deployment is the moment a project stops being a local exercise and becomes something other people can use. In the full book, deployment is not an afterthought: you build real projects and take them to production with environment-based config, Docker, CI/CD, and cloud hosting. 30 chapters, six portfolio projects covering Flask, OAuth, SQLite, Postgres, Docker, CI/CD, and AWS.
Get the book for €35Chapter 3 is free to read.