Last verified: October 2025 | Docker: 24.x | Flask: 3.0.0 | PostgreSQL: 15 | Python: 3.11
Deployment is frustrating. You get your Flask app working perfectly on your machine, then spend hours fighting with server configuration, Python versions, and missing dependencies when you try to deploy it. Docker fixes this by packaging everything your app needs into a container that runs the same way everywhere.
This guide shows you how to containerize a Flask blog and deploy it to Fly.io. The whole process takes about 15 minutes once you know what you're doing. No server configuration, no dependency hell, just your app running in production.
Who This Guide Is For
If you have a Flask app that works locally and you want to deploy it without the usual headaches, this is for you. Maybe you're tired of debugging why something works on your laptop but breaks in production. Maybe you want to use modern deployment practices without spending weeks learning DevOps.
You'll need basic Python and Flask knowledge. If you've built a Flask app before, you're ready. You should be comfortable with the command line, and you'll need Docker Desktop installed. That's it.
By the end, you'll have local development that matches production exactly, deployments that take minutes instead of hours, and no more Python version conflicts.
Why Docker Helps
Docker gets a bad rap for being complex, but the core idea is simple: it's a box for your code. Your app goes in the box with everything it needs to run. That box runs the same way on your laptop, on CI servers, and in production.
This consistency eliminates entire categories of bugs. No more "but it works on my machine" discussions. No more discovering that production has Python 3.8 when you developed on 3.11. No more missing system libraries that you forgot to document.
The real win is deployment speed. Once you have a working Docker setup, deploying becomes trivial. Push your code, build the image, deploy. Rollbacks are just as easy: redeploy the previous image and you're back in business in seconds.
What We're Building
We're going to take your existing Flask blog and containerize it properly. You'll get local development with hot reloading so you can see changes instantly. We'll use PostgreSQL because SQLite isn't meant for production, no matter what anyone tells you. Then we'll deploy the whole thing to Fly.io because they have a generous free tier and run your Docker containers as-is.
Prepping Your Flask App
First, let's fix your requirements.txt. If yours just says "Flask" with no version number, you're asking for trouble. Here's what you need for production:
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.5
psycopg2-binary==2.9.9
python-dotenv==1.0.0
gunicorn==21.2.0Flask's built-in server is for development only. Gunicorn is a production-ready WSGI server that can handle real traffic. The psycopg2-binary package lets you talk to PostgreSQL. Everything has version numbers because "Flask" without a version means you might get different versions in different environments, which defeats the whole point.
Create a wsgi.py file as your application entry point:
from app import create_app
import os
app = create_app()
if __name__ == "__main__":
app.run(
host="0.0.0.0",
port=int(os.environ.get("PORT", 8080))
)That host="0.0.0.0" is important. It means "listen on all network interfaces." The default 127.0.0.1 only listens locally, which doesn't work inside a container. This tiny detail has caused hours of debugging for many developers.
Your config.py should use environment variables for anything sensitive:
import os
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(basedir, '.env'))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
f'sqlite:///{os.path.join(basedir, "app.db")}'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Some services use postgres:// but SQLAlchemy needs postgresql://
if SQLALCHEMY_DATABASE_URI.startswith("postgres://"):
SQLALCHEMY_DATABASE_URI = SQLALCHEMY_DATABASE_URI.replace(
"postgres://", "postgresql://", 1
)Never hardcode secrets. Use environment variables. Your future self will thank you when you don't accidentally commit your production database password to GitHub.
Writing a Dockerfile That Works
Here's a Dockerfile that works in production:
FROM python:3.11-slim as base
# Python buffering can cause issues in containers
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create non-root user for security
RUN useradd -m -u 1000 flaskuser && \
chown -R flaskuser:flaskuser /app
USER flaskuser
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "wsgi:app"]The order matters here. Copying requirements.txt before copying your code means Docker can cache the pip install layer. When you change your code but not your dependencies, Docker reuses the cached layer with all your packages already installed. This turns a 5-minute rebuild into a 10-second one.
Running as a non-root user is a security best practice. If someone compromises your app, they won't have root access to the container.
Docker Compose for Local Development
Docker Compose lets you run multiple containers together. Here's a docker-compose.yml that sets up your Flask app with PostgreSQL:
version: '3.8'
services:
web:
build: .
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/flask_blog
- SECRET_KEY=dev-secret-key
- FLASK_ENV=development
volumes:
- .:/app # Mount your code for hot reloading
depends_on:
- db
command: flask run --host=0.0.0.0 --port=8080 --reload
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=flask_blog
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:The volume mount (.:/app) means changes to your code are reflected immediately without rebuilding the container. The postgres_data volume ensures your database persists even when you stop the containers.
That depends_on just controls startup order. It doesn't wait for PostgreSQL to be ready, so your Flask app might crash on first startup. Just restart it; this is normal and only happens once.
Running Everything
Build your image first:
docker-compose buildThe first build takes a while as it downloads base images and installs packages. Subsequent builds are much faster thanks to Docker's layer caching.
Start the database:
docker-compose up -d dbInitialize your database migrations if you haven't already:
docker-compose run --rm web flask db init
docker-compose run --rm web flask db migrate -m "Initial migration"
docker-compose run --rm web flask db upgradeNow start everything:
docker-compose upVisit localhost:8080 and your blog should be running. If something's wrong, the logs will tell you exactly what. Docker shows you everything, which is way better than mysterious production failures with no error messages.
To see just your app logs without PostgreSQL noise, use docker-compose logs -f web.
Deploying to Fly.io
Fly.io runs your actual Docker container, not some interpreted version of it. They have a free tier that doesn't require a credit card, which is refreshing in 2025. For a deeper dive into Fly.io's free tier capabilities and limitations, including performance comparisons and scaling strategies, check out our complete guide.
Install their CLI first.
On Mac:
brew install flyctlOn Windows, download the installer from their website or use PowerShell:
pwsh -Command "iwr https://fly.io/install.ps1 -useb | iex"On Linux:
curl -L https://fly.io/install.sh | shYou might need to add flyctl to your PATH. The installer will tell you if this is needed and show you the exact command to run.
Launch your app with flyctl launch. When it asks, give your app a name (or let it generate one), pick a region close to you or your users, say yes to PostgreSQL (pick development for free tier), and say no to deploying immediately because we need to set secrets first.
This creates a fly.toml file that configures your deployment:
app = "your-app-name"
primary_region = "iad"
[build]
[env]
PORT = "8080"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 256The auto_stop_machines setting stops your app when nobody's using it, which helps you stay within free tier limits. It starts again automatically when someone visits.
Set your secret key:
flyctl secrets set SECRET_KEY=$(openssl rand -hex 32)Deploy your app:
flyctl deployThis builds your Docker image and deploys it. Takes about 2 minutes. When it's done, run your database migrations:
flyctl ssh console -C "flask db upgrade"Open your deployed app with flyctl open. You now have a production Flask app with HTTPS, automatic restarts, and database backups. No server configuration needed.
Common Issues and Solutions
When you see "Port already in use", you probably have another container running. Run docker-compose down to stop everything, or just change the port. Sometimes Docker Desktop gets confused and a restart fixes it.
Database connection errors on startup usually mean Flask is trying to connect before PostgreSQL is ready. Just restart the Flask container. You could add retry logic to your database connection code, but for local development, a simple restart works fine.
If Docker Desktop says it can't connect to the Docker daemon, restart Docker Desktop. If that doesn't work, restart your computer. Docker Desktop has quirks.
The Fly.io free tier gives you 256MB of RAM. If your app needs more, run flyctl scale memory 512. Or optimize your app; you probably don't need to load everything into memory at once.
When changes aren't showing up locally, check that volume mount in docker-compose.yml. In production, you need to rebuild and deploy with flyctl deploy. Docker doesn't automatically detect code changes in production.
Tips That Save Time
Docker images can get huge. Use slim base images and add a .dockerignore file:
__pycache__
*.pyc
.git
.env
venv/
node_modules/
*.db
.DS_StoreAdd proper logging to your app. When something breaks in production, logs are your only window into what happened:
import logging
logging.basicConfig(level=logging.INFO)
app.logger.info('App starting up')The Docker setup you just created is the same one many companies use in production. This isn't a toy setup; it's professional-grade deployment that happens to be free.
You Made It
You've successfully containerized a Flask application with a production database, local hot reloading, and one-command deployment. Your development environment now matches production exactly, eliminating an entire class of deployment bugs.
This is the same workflow professional teams use. The same Docker, the same deployment process, the same everything. The gap between hobby projects and production applications is smaller than most people think. It's mostly just knowing the right tools.
Build Your Flask Application First
Don't have a Flask blog to dockerize yet? Start here:
- Build a Blog with Flask - Create a complete Flask blog from scratch with PostgreSQL and authentication
- Build a Portfolio with Flask - Learn Flask fundamentals with a professional portfolio site
- Build E-Commerce with Flask - Master Flask with a full shopping cart and payment integration
Each tutorial includes step-by-step AI prompts to guide you through building production-ready Flask applications. Once you've built your app, come back to this guide to dockerize and deploy it.
Now go build something cool.
Fred
AUTHORFull-stack developer with 10+ years building production applications. I've containerized dozens of production applications and learned these lessons the hard way.

