> ## Content Index
> Fetch the complete content index at: https://blog.hostly.ninja/llms.txt
> Use this file to discover other available public pages before exploring further.

# How do I package my Python app for production? A Docker Guide.
- URL: https://blog.hostly.ninja/how-do-i-package-my-python-app-for-production-a-docker-guide/
- Published: 2026-09-21T08:00:26.000Z
- Updated: 2026-11-02T18:03:04.000Z
- Description: Want to take your Python Flask app from local development to production? This guide shows you the end-to-end chain from running it with Gunicorn to packaging with Docker, testing it locally, publishing it to Docker Hub, and deploying it to the web with Hostly.
- Author: Andreas Knipl
- Tags: Docker, tutorial

So you crafted a web-app using Python and a web application framework like Flask and now you want to package your project for further distribution? In the following article you will learn how to containerize your Python Flask Application using Docker and how to quickly deploy your application on Hostly afterwards!

## How to dockerize a Python Flask Application

A Python application that runs on your computer still needs a predictable runtime, its dependencies, and a process that can serve requests once it is deployed. A container image packages those pieces together so the application can run on another machine without recreating your local Python setup.

In this tutorial, we will create a small Flask API that reads part of its response from an environment variable. We will then package it with Gunicorn, test the container locally, publish the image to Docker Hub, and deploy it on Hostly.

[![CTA Image](https://blog.hostly.ninja/content/images/2026/09/question_asking-1.webp)](https://docs.hostly.ninja/getting-started/?ref=blog.hostly.ninja) 

Want to know more about Hostly and how it works?

[Check the docs! ](https://docs.hostly.ninja/getting-started/?ref=blog.hostly.ninja) 

The example is intentionally small. There will be no database, authentication, virtual environment, or complex project structure. The goal is to understand the basic route from a Python file to a deployable image.

## What you will need

Before starting, make sure you have:

- *Docker Desktop* or the *Docker Engine*
- A *Docker Hub account* (if you want to publish your image)
- A text editor or IDE
- A terminal (the tutorial is written with a linux shell in mind)

You do not need to install *Flask* or *Gunicorn* directly on your computer. *Docker* will install them while building the image.

The tutorial uses *Python 3.14*, whose latest stable maintenance release at the time of writing is *Python 3.14.6*. For our image, we will use the official `python:3.14-slim` image so that future builds can receive newer Python 3.14 patch releases without unexpectedly moving to another minor version. But more on this later.

## 1\. Create the project

Create a directory for the application and cd into it:

```Shell
$ mkdir hello-flask-api && cd hello-flask-api
```

The finished minimum project will contain the following four files in this directory:

```
hello-flask-api/
├── app.py
├── requirements.txt
├── .dockerignore
└── Dockerfile
```

We will create each one in the following sections.

## 2\. Create the Flask application

Create a file named `app.py` and copy the following example code into it:

```Python
import os
from flask import Flask, jsonify

app = Flask(__name__)

@app.get("/")
def hello():
    target = os.getenv("GREETING_TARGET", "World")

    return jsonify(
        message=f"Hello, {target}!"
    )

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=3000)
```

This is a basic Flask app, exposing a single API endpoint at `/`. It reads the `GREETING_TARGET` environment variable and uses its value in the response.

When the variable is not set, the application uses `World` as its default value and answers requests with the following JSON:

```JSON
{
  "message": "Hello, World!"
}
```

Later, we will configure `GREETING_TARGET` through *Hostly* without changing or rebuilding the image.

The `if __name__ == "__main__":` block allows the application to be started directly with the command `python app.py` during local development. It binds the API to the IP `0.0.0.0`, which makes the server reachable from outside its own process environment, including when it is run in a container. This will be useful later, when we want to containerize the application.

The built-in Flask server is intended for development rather than production deployments. Our image will therefore use Gunicorn to run the application when the container starts. Flask’s deployment documentation recommends using a dedicated WSGI (Web Server Gateway Interface) server for production workloads and documents Gunicorn as one of the supported options.

## 3\. Declare the dependencies

Create a file named `requirements.txt` and copy the following lines into it:

```txt
Flask==3.1.3
gunicorn==26.0.0
```

These are dependencies that Python will use to run your application.

💡

Did you know?  
**Flask* provides the web application framework, while **Gunicorn* provides the production webserver that accepts HTTP requests and passes them to the Flask application.

The versions are pinned so that rebuilding the image installs the same application dependencies rather than silently selecting newer releases. At the time of writing, these are the current *Flask* and *Gunicorn* releases.

For a larger application, `requirements.txt` can be much more extensive, as it would also contain packages for database access, authentication, validation, logging, or any other functionality the application uses.

## 4\. Add a `.dockerignore` file

When Docker builds an image, it sends the project directory to the Docker builder as the build context. To not bloat the container or share private files (like API keys), you should exclude those files. Docker does this in a similar way to Git and its `.gitignore`.

Create a file named `.dockerignore` and copy the following lines of text into it:

```Dockerignore
__pycache__/
*.py[cod]
*.log

.venv/
venv/

.env
.env.*

.git/
.gitignore

.pytest_cache/
.coverage
htmlcov/
```

Even though this tutorial does not create a virtual environment, excluding common virtual-environment directories prevents one from being copied accidentally if the project is extended later on.

The `.env` entries are especially important. Environment files frequently contain passwords, API keys, or other configuration values that **should not** be embedded in an image. Those values should only be provided when the container is deployed.

## 5\. Create the Dockerfile

Create a file named `Dockerfile` without any file extension and insert the following content. Don't worry, we will explain every step!

```Dockerfile
# Base image
FROM python:3.14-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt ./

# Install requirements/dependencies into the image
RUN pip install --no-cache-dir -r requirements.txt \
    && useradd \
        --create-home \
        --user-group \
        --uid 10001 \
        --shell /usr/sbin/nologin \
        appuser

# Use dedicated non-root user for web-app
COPY --chown=appuser:appuser app.py ./

USER appuser

# The port to expose to the outside world
EXPOSE 3000

# What to execute on startup
CMD [
    "gunicorn",
    "--bind",
    "0.0.0.0:3000",
    "--workers",
    "2",
    "--access-logfile",
    "-",
    "app:app"
]
```

This is still a relatively small Dockerfile, but it includes a few practices that make it more appropriate for an actual deployment.

### Choose the Python base image

```Dockerfile
FROM python:3.14-slim
```

Every container image starts from a base image. The official Python image already contains the Python interpreter and the basic tooling needed to install Python packages.

The `slim` variant is based on a reduced Debian installation. It is smaller than the full Python image while retaining the broad package compatibility of a Debian-based environment. The official *Python* image publishes multiple variants for different size and compatibility requirements.

Some *Python* packages include native extensions and may require additional operating-system libraries or a compiler during installation. Our *Flask* application uses pure *Python* dependencies, so it does not require a separate build stage or additional Debian packages.

### Configure Python for containers

```Dockerfile
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1
```

`PYTHONDONTWRITEBYTECODE=1` prevents Python from creating `.pyc` bytecode files inside the container.

`PYTHONUNBUFFERED=1` causes output written to standard output and standard error to be forwarded immediately. That is useful in a container because application logs are normally collected from those output streams.

### Set the working directory

```Dockerfile
WORKDIR /app
```

The remaining Dockerfile instructions operate relative to `/app`. This is also the directory from which *Gunicorn* will start.

### Copy the dependency file first

```Dockerfile
COPY requirements.txt ./
```

The dependency file is copied before the application source so Docker can cache the dependency-installation layer.

When `app.py` changes but `requirements.txt` does not, Docker may reuse the previously installed dependencies instead of downloading them again. Structuring Dockerfiles around cacheable dependency layers is a standard way to make repeated builds faster. If you are new to Docker, this is not as important, but will become more important the bigger and more complex your images get.

### Install the dependencies

```Dockerfile
RUN pip install --no-cache-dir -r requirements.txt \
    && useradd \
        --create-home \
        --user-group \
        --uid 10001 \
        --shell /usr/sbin/nologin \
        appuser
```

`pip` installs *Flask*, *Gunicorn*, and their dependencies from `requirements.txt`.

The `--no-cache-dir` option prevents downloaded package archives from remaining in the finished image after installation.

The same build step creates an unprivileged Linux user named `appuser`. The application does not need administrative permissions, so there is no reason to run it as root.

The numeric user-ID is set explicitly instead of relying on whichever default ID the base image assigns. This makes the container identity more predictable if the image is later used with mounted storage or stricter runtime security policies.

### Copy the application

```Dockerfile
COPY --chown=appuser:appuser app.py ./
```

The application source is copied into `/app` and assigned to the non-root user.

For a larger project, you might copy an entire package or source directory instead:

```Dockerfile
COPY --chown=appuser:appuser src ./src
```

For this example, the application consists of only one file.

### Switch to the non-root user

```Dockerfile
USER appuser
```

Every following instruction, including the command that starts the application, runs as `appuser`.

Running the application without root privileges reduces the attack surface if the application or one of its dependencies were compromised. Docker’s image-building guidance includes running the final application process as a non-root user as part of a production-oriented baseline.

### Document the application port

```Dockerfile
EXPOSE 3000
```

The *Flask* application and *Gunicorn* will listen on port `3000`.

`EXPOSE` documents that port in the image metadata. It does not make the container publicly accessible by itself. When running the image locally, we still need to publish the port with `docker run -p`. When we later on run it on **Hostly**, we will enter `3000` as the container port to make it accessible from the web.

### Start the application with Gunicorn

```Dockerfile
CMD [
    "gunicorn",
    "--bind",
    "0.0.0.0:3000",
    "--workers",
    "2",
    "--access-logfile",
    "-",
    "app:app"
]
```

The final argument, `app:app`, tells *Gunicorn* what to load:

- The first `app` refers to the `app.py` module.
- The second `app` refers to the *Flask* application created by `app = Flask(__name__)`.

This follows *Gunicorn*’s `{module}:{application}` loading syntax, which we will not go into detail in this tutorial.

*Gunicorn* binds to `0.0.0.0:3000`, allowing traffic to reach it at port `3000` and later on to also reach it from outside the container.

The example starts two worker processes. One worker can only execute one synchronous request at a time, so two workers provide a more useful starting point for a small web service. Whenever you run anything more sophisticated than this example, you should adjust the worker count according to the workload and available memory rather than keeping this as a static value.

`--access-logfile -` writes HTTP access logs to standard output, allowing them to appear alongside the other container logs, which is just a convenient way to have everything in one place for something quick and small like a tutorial app.

## 6\. Build the image

The project should now look like this:

```
hello-flask-api/
├── app.py
├── requirements.txt
├── .dockerignore
└── Dockerfile
```

Build the image from the project directory:

```Shell
$ docker build -t hello-flask-api:1.0.0 .
```

The `-t` option assigns the image a name and version tag:

```
hello-flask-api:1.0.0
```

The final period after the version tag tells Docker to use the current directory as the build context (your project directory).

During the build, Docker will:

1. Download the *Python* base image
2. Copy `requirements.txt`
3. Install *Flask* and *Gunicorn* into the image
4. Create the non-root user `appuser`
5. Copy `app.py`
6. Save the result as a new image

After the build completes, confirm that the image exists:

```Shell
$ docker image ls
```

You should see `hello-flask-api` with the tag `1.0.0`.

## 7\. Run the image locally

Now that we have our built a custom Docker-Image, we can try to run it locally and see what happens. To start a container from the image, run the follow command:

```Shell
$ docker run \
    --rm \
    -p 3000:3000 \
    hello-flask-api:1.0.0
```

The port mapping has the following structure:

```
HOST_PORT:CONTAINER_PORT
```

With this the traffic sent to port `3000` on your computer is forwarded to port `3000` inside the container.

Open the following address in your browser:

```
http://localhost:3000
```

Alternatively, you can just use *curl*:

```Shell
$ curl http://localhost:3000
```

Because no greeting variable was provided, the response uses the default value:

```JSON
{
  "message": "Hello, World!"
}
```

If you now want to stop the container, you can simply do this with `Ctrl+C`.

### Test the environment variable

Now run the image again, but this time we will be setting `GREETING_TARGET`:

```Shell
$ docker run \
    --rm \
    -p 3000:3000 \
    -e GREETING_TARGET=Container \
    hello-flask-api:1.0.0
```

Request the endpoint again, e.g. with *curl*:

```Shell
$ curl http://localhost:3000
```

The response should now be:

```JSON
{
  "message": "Hello, Container!"
}
```

The same image produced both responses by only changing its runtime configuration.

This is one of the most useful properties of environment-based configuration: the application image does not need to be rebuilt for every deployment environment. Depending on your use-case those configurable feature flags can save you a lot of build time and enable a wide variety of complex configurations. But because of the possible complexity of the configuration options you will want to judge carefully when and how to use them, so you won't get into an undefined state for your application.

## 8\. Publish the image to Docker Hub

The image currently exists only in your local Docker environment. To run it on Hostly, the image needs to be pulled in from a container registry.

The next steps will teach you the basics of publishing an image onto Docker Hub.

1. Create a public Docker Hub repository named:

```
hello-flask-api
```

1. Authenticate from your terminal:

```Shell
$ docker login
```

1. Tag the local image with your Docker Hub username:

```Shell
$ docker tag \
    hello-flask-api:1.0.0 \
    YOUR_USERNAME/hello-flask-api:1.0.0
```

Replace `YOUR_USERNAME` with your actual Docker Hub username.

1. Push the image to Docker Hub.

```Shell
$ docker push YOUR_USERNAME/hello-flask-api:1.0.0
```

`docker push` uploads a tagged image to Docker Hub or another configured registry. Registry authentication is handled through `docker login`.

1. Once the upload finishes, the complete image address will be:

```
docker.io/YOUR_USERNAME/hello-flask-api:1.0.0
```

Using an explicit version tag makes it possible to tell exactly which application version a deployment is running - something that can help you in debugging, once your application is running. Whenever the application changes, you can publish a new tag such as `1.0.1`.

### Building for more than one CPU architecture

A normal `docker build` creates an image for the architecture used by your Docker environment. Depending on your computer, that will commonly be either *AMD64* or *ARM64*.

To publish a single image with support for both architectures, use *Docker Buildx*:

```Shell
$ docker buildx build \
    --platform linux/amd64,linux/arm64 \
    -t YOUR_USERNAME/hello-flask-api:1.0.0 \
    --push .
```

A multi-platform build publishes multiple architecture-specific image variants under one image name. The container platform can then select the compatible variant when pulling the image.

Pure Python packages are usually straightforward to build for multiple architectures. Projects with compiled dependencies may need additional build tools or architecture-specific packages. You will need to consult the appropriate framework documentation to find out how.

## 9\. Deploy the image on Hostly

The published image now contains everything required to run the application: *Python*, *Flask*, *Gunicorn*, the installed dependencies, and the application source.

Create a new service in **Hostly** and enter:

- Your desired `hostly.ninja` subdomain
- Image URL: `docker.io/YOUR_USERNAME/hello-flask-api:1.0.0`
- Container port: `3000`

Add the following environment variable to get a customized example running:

```
GREETING_TARGET=Hostly
```

The example does not require secrets, volumes, or configuration files, so those sections can remain empty and you can click right through them.

Finally click **Deploy**.

**Hostly** will pull the image, start the container, route traffic to port `3000`, and provide SSL for the selected subdomain. The service health check only needs to establish a successful TCP connection to the configured port, so the *Flask* application does not need a separate health endpoint - something a bigger codebase would want to ship as well, to see if everything is running as expected.

Opening the assigned address should return:

```JSON
{
  "message": "Hello, Hostly!"
}
```

This tutorial uses a public Docker Hub repository to keep the publishing and deployment steps simple, but **Hostly** is able to also pull from private repositories when the required registry credentials are configured appropriately.

## What you have built

Let us recap what we have done and learned in this tutorial: You started with a single *Python* file and produced an image that can run consistently on your computer or on **Hostly**.

The application demonstrates the basic responsibilities of a containerized *Flask* service:

1. Declare application dependencies in `requirements.txt`
2. Use a production WSGI server instead of exposing Flask’s development server
3. Bind the server to `0.0.0.0`
4. Keep runtime configuration outside the image
5. Install dependencies before copying frequently changing source files
6. Run the application as a non-root user
7. Test the completed image locally
8. Publish the image to a container registry
9. Deploy it using its image URL, port, and environment variables

A larger *Flask* application may contain additions like blueprints, database models, migrations, templates, and many more dependencies, but the deployment remains mostly the same: package the application into an image, make it listen on a known port, and supply its environment-specific configuration when the container starts. Afterwards you can deploy and test, that everything is working as expected.

[![CTA Image](https://blog.hostly.ninja/content/images/2026/09/Logo-4.png)](https://hostly.ninja/?ref=blog.hostly.ninja) 

Have fun building your dockerized Python applications on ****Hostly**!

[Get Started on Hostly! ](https://hostly.ninja/?ref=blog.hostly.ninja)