A single docker-compose.yml designed for Portainer that clones a Flask app from GitHub, installs its dependencies, runs it under Gunicorn, and automatically pulls updates on a configurable interval — all with no custom image build required.
- How It Works
- Requirements
- Deploying in Portainer
- Environment Variables
- Authentication
- Storage Volumes
- Auto-Update Watcher
- Multiple Flask Apps
- Flask App Requirements
- Gunicorn Tuning
- Troubleshooting
On every container start, the following steps run in order:
- System packages — installs
git,openssh-client,ca-certificates, andcurlviaapt-get - Gunicorn — installed via
uv - Auth — configures git credentials (token or SSH key) if the repo is private
- Clone or pull — clones the repo on first start; pulls the latest on subsequent starts
- App dependencies — installs from
pyproject.toml(editable install) orrequirements.txt - Storage dirs — creates the dynamic and static directories if they don't exist
- Gunicorn — starts in the background, bound to
0.0.0.0:<APP_PORT> - Watcher — a background loop polls
git fetcheveryUPDATE_INTERVALseconds; when a new commit is detected it pulls the changes, reinstalls dependencies, and sendsSIGHUPto Gunicorn for a graceful reload with zero downtime
The container process waits on Gunicorn and handles SIGTERM/SIGINT for clean shutdown.
- Docker Engine 20.10+
- Docker Compose v2.17+ (bundled with Docker Desktop and recent Docker Engine installs)
- Portainer CE or BE (any recent version)
- A Flask app in a GitHub repository
- In Portainer, go to Stacks → Add Stack
- Give the stack a meaningful name (e.g.
myapp,blog,api) — this name prefixes the volume names, keeping each app isolated - Select Web editor and paste the entire contents of
docker-compose.yml - Scroll down to Environment variables and add the variables for your app (see table below)
- Click Deploy the stack
To deploy a second app, repeat the steps with a different stack name and different environment variables. Each stack is fully independent.
Tip: You can also store this file in a git repository and point Portainer at it using the Repository option instead of pasting. Portainer will pull the compose file from the repo on each redeploy.
Set these in the Portainer Environment variables section when creating or editing the stack.
| Variable | Description |
|---|---|
GITHUB_REPO |
Full clone URL of the Flask app repository (see Authentication for format) |
| Variable | Default | Description |
|---|---|---|
GITHUB_BRANCH |
main |
Branch to clone and track for updates |
APP_MODULE |
app:app |
filename_without_extension:flask_variable_name. The first part is the .py file that contains the Flask object (e.g. app.py → app, wsgi.py → wsgi, wgapp.py → wgapp). The second part is the variable name assigned to Flask(...) inside that file. See Flask App Requirements for a full lookup table. |
APP_PORT |
5000 |
Port that Gunicorn listens on inside the container, and the host port it maps to |
GUNICORN_WORKERS |
2 |
Number of Gunicorn worker processes (see Gunicorn Tuning) |
GUNICORN_THREADS |
2 |
Threads per worker |
| Variable | Default | Description |
|---|---|---|
REPO_ACCESS |
public |
Set to private to enable auth |
REPO_AUTH_TYPE |
token |
token for a GitHub personal access token, ssh for an SSH key |
GITHUB_TOKEN |
(empty) | GitHub personal access token — required when REPO_AUTH_TYPE=token |
SSH_KEY_PATH |
/run/secrets/ssh_key |
Path to the SSH private key inside the container — required when REPO_AUTH_TYPE=ssh |
| Variable | Default | Description |
|---|---|---|
DYNAMIC_STORAGE |
/data/dynamic |
Container path for writable runtime data (uploads, SQLite databases, logs, etc.) |
STATIC_STORAGE |
/data/static |
Container path for static assets |
| Variable | Default | Description |
|---|---|---|
UPDATE_INTERVAL |
60 |
Seconds between git fetch checks. Set to 0 to disable (not recommended — use a large number like 86400 instead) |
Set GITHUB_REPO to the HTTPS clone URL. No other auth variables are needed.
GITHUB_REPO=https://github.com/your-user/your-repo.git
- In GitHub, go to Settings → Developer settings → Personal access tokens → Fine-grained tokens
- Create a token with Contents: Read permission on the target repository
- Set the following variables:
GITHUB_REPO=https://github.com/your-user/your-repo.git
REPO_ACCESS=private
REPO_AUTH_TYPE=token
GITHUB_TOKEN=github_pat_xxxxxxxxxxxx
The token is stored in ~/.git-credentials inside the container using git's credential store. It is never embedded in the clone URL (which would expose it in process listings).
- Generate a deploy key for the repository:
ssh-keygen -t ed25519 -C "portainer-deploy" -f ./deploy_key -N ""
- In GitHub, go to your repo → Settings → Deploy keys → Add deploy key
- Paste the contents of
deploy_key.puband enable Allow read access - Mount the private key into the container by adding a volume entry in the compose file:
volumes: - app_dynamic:${DYNAMIC_STORAGE:-/data/dynamic} - app_static:${STATIC_STORAGE:-/data/static} - /path/on/host/deploy_key:/run/secrets/ssh_key:ro
- Set the following variables:
GITHUB_REPO=git@github.com:your-user/your-repo.git
REPO_ACCESS=private
REPO_AUTH_TYPE=ssh
Security: The private key file on the host should be owned by root and mode
600. The:romount flag prevents the container from modifying it.
The stack creates two named Docker volumes automatically:
| Volume name (Portainer prefix + name) | Default mount point | Purpose |
|---|---|---|
<stackname>_app_dynamic |
/data/dynamic |
Writable runtime data — persists across container restarts and redeployments |
<stackname>_app_static |
/data/static |
Static assets — persists across restarts |
Because Portainer prefixes volume names with the stack name (e.g. myapp_app_dynamic), volumes from different stacks never collide.
Your Flask app should read DYNAMIC_STORAGE and STATIC_STORAGE from the environment to locate these directories:
import os
UPLOAD_FOLDER = os.environ.get("DYNAMIC_STORAGE", "/data/dynamic")
STATIC_FOLDER = os.environ.get("STATIC_STORAGE", "/data/static")To change the mount path (e.g. to /mnt/uploads), set DYNAMIC_STORAGE=/mnt/uploads — the volume will be mounted at that path and the env var will reflect it.
A background process runs inside the container and checks for new commits on the tracked branch every UPDATE_INTERVAL seconds.
What happens when an update is detected:
git reset --hard origin/<branch>— applies the new commitsuv pip install— reinstalls dependencies in caserequirements.txtorpyproject.tomlchangedkill -HUP <gunicorn_pid>— sendsSIGHUPto the Gunicorn master process, triggering a graceful reload (workers finish their current requests before being replaced by new workers running the updated code)
Considerations:
- Database schema migrations are not run automatically. If your app requires migrations on deploy (e.g. Flask-Migrate / Alembic), you will need to handle this separately or add a migration step to your app's startup code.
- If a dependency installation fails during the update, Gunicorn is not reloaded — the running app continues serving the previous version.
- To force an immediate update, restart the stack in Portainer. The container will pull the latest code on startup before Gunicorn starts.
Each Portainer stack runs one Flask app. To host multiple apps:
- Deploy this
docker-compose.ymlas a new stack for each app - Use a different stack name for each (e.g.
app-blog,app-api,app-dashboard) - Set a unique
APP_PORTfor each stack so host ports don't conflict
Example port assignments:
| Stack name | APP_PORT |
|---|---|
app-blog |
5000 |
app-api |
5001 |
app-dashboard |
5002 |
Each stack gets its own isolated volumes (app-blog_app_dynamic, app-api_app_dynamic, etc.) and its own update watcher.
The Flask app repository must meet these requirements for the stack to work correctly:
Entry point
APP_MODULE follows gunicorn's module:variable format:
module— the Python file that contains your Flask object, written as its name without the.pyextension. This is NOT a directory or package name, it is a file name.variable— the name of the variable assigned toFlask(...)inside that file.
To find the correct values, open the main Python file in your repo and look for the Flask( constructor:
# Example: the file is wgapp.py and the object is named wgapp
from flask import Flask
wgapp = Flask(__name__) # → APP_MODULE=wgapp:wgapp| File in repo root | Flask line | APP_MODULE value |
|---|---|---|
app.py |
app = Flask(__name__) |
app:app (default) |
app.py |
application = Flask(__name__) |
app:application |
wsgi.py |
app = Flask(__name__) |
wsgi:app |
wgapp.py |
wgapp = Flask(__name__) |
wgapp:wgapp |
main.py |
app = Flask(__name__) |
main:app |
run.py |
server = Flask(__name__) |
run:server |
Common mistake: setting
APP_MODULEto the project or package name frompyproject.tomlinstead of the actual.pyfilename. Thenamefield inpyproject.tomlis irrelevant — only the filename and variable name matter.
Dependencies
Include either a requirements.txt or a pyproject.toml at the repo root. The stack auto-detects which one to use. If neither is present, only Flask itself is installed as a fallback.
# requirements.txt example
flask>=3.0
flask-sqlalchemy
gunicorn
gunicornis already installed at the system level by the stack, so it does not need to be inrequirements.txt— but including it there is harmless.
The recommended formula for GUNICORN_WORKERS is:
workers = (2 × CPU cores) + 1
For a 2-core host: GUNICORN_WORKERS=5
For a 4-core host: GUNICORN_WORKERS=9
GUNICORN_THREADS controls threads per worker. For I/O-bound apps (APIs, database queries), increasing threads can improve throughput without increasing memory usage significantly. A value of 2–4 is a reasonable starting point.
| Use case | Suggested workers | Suggested threads |
|---|---|---|
| Low traffic / home lab | 2 |
2 |
| Medium traffic | (2 × cores) + 1 |
2 |
| I/O-heavy app | 2–4 |
4–8 |
Container exits immediately on start
Check the container logs in Portainer (Containers → select container → Logs). Common causes:
GITHUB_REPOis not setGITHUB_TOKENis missing whenREPO_ACCESS=privateandREPO_AUTH_TYPE=token- SSH key file not found at
SSH_KEY_PATH APP_MODULEpoints to a module or variable that doesn't exist in the cloned repo
App is not found / 502 from reverse proxy
- Confirm
APP_PORTmatches the port your reverse proxy is forwarding to - Check that Gunicorn started successfully in the logs — look for
[gunicorn] Starting on 0.0.0.0:<port> - Verify
APP_MODULEmatches your Flask app's file and variable name
Updates are not being applied
- Check the watcher logs in the container output — look for
[watcher]lines - Confirm the container has outbound internet access to reach GitHub
- If using a private repo, ensure the token or SSH key has read access to the repository
- The watcher only reloads Gunicorn when the git commit hash changes — make sure you are pushing to the branch set in
GITHUB_BRANCH
Port conflict when deploying multiple apps
Each stack must use a unique APP_PORT. Check Portainer's container list for which ports are already in use before deploying a new stack.
"Permission denied" on SSH key
The SSH key file must be readable by root inside the container. On the host, ensure the file mode is 600:
chmod 600 /path/to/deploy_key