Deploying a Flask app on your own server with Coolify
Ship a small Flask app on your own server with a Dockerfile, gunicorn, a persistent volume, reverse-proxy headers and environment variables.
There are many ways to put a small Flask application online: managed platforms, a hand-configured virtual server, container services. We run most of our products on our own server with Coolify, an open-source self-hosting panel. Coolify builds an image from a Git repository, runs the container, attaches the domain and its TLS certificate, and manages environment variables and persistent volumes. In short, it offers much of the convenience of a managed platform while the server and the data stay with you.
In this article we walk through how we publish the very site you are reading. The focus is not Coolify's interface but what the application itself must do to behave correctly behind a reverse proxy. These details apply whichever platform you choose, and when they are skipped, the resulting bugs tend to appear after going live, in the quietest possible way.
A plain Dockerfile
Coolify can read the Dockerfile in the repository and build the image itself. A Flask application does not need a long Dockerfile. Ours is very close to this:
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PORT=8020
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY . .
# the SQLite file lives on a persistent volume
VOLUME ["/app/instance"]
EXPOSE 8020
CMD ["sh", "-c", "gunicorn -w 2 -b 0.0.0.0:${PORT} 'app:create_app()'"]
A few details matter. PYTHONUNBUFFERED=1 makes log lines written by the application reach the container log immediately instead of sitting in a buffer, which makes a big difference when you are watching logs in the panel. Copying requirements.txt before the rest of the code preserves Docker's layer cache, so packages are not reinstalled when only the code changes. Gunicorn accepts an application factory in the form 'app:create_app()', which means the app is built by a factory function rather than a module-level variable, and the structure you test is the same structure that runs in production.
Reading the port from an environment variable is a small but useful habit as well. If the platform expects another port, you change a variable, not the image.
Correct client IP and scheme behind a reverse proxy
On Coolify your application is not exposed to the internet directly; a reverse proxy in front of it terminates HTTPS and forwards the request to the container. Flask therefore sees a plain HTTP request coming from the proxy. The remote address is the proxy's address and the scheme is http. The consequences can be subtle: a per-user rate limit treats every visitor as one person, url_for(..., _external=True) produces http:// URLs, and secure-cookie decisions are made on wrong information.
The fix is Werkzeug's ProxyFix middleware. It reads the X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host headers added by the proxy and updates the request with the real values. The one thing to get right is how many proxies you trust. If the number is too high, a visitor can write these headers themselves and spoof their IP address. So instead of hard-coding the number we read it from an environment variable and default to zero:
import os
from flask import Flask
def create_app() -> Flask:
app = Flask(__name__, instance_relative_config=True)
proxies = int(os.environ.get("PROXY_SAYISI", "0") or 0)
if proxies > 0:
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=proxies, x_proto=proxies, x_host=proxies)
return app
Locally the variable is not set, so the middleware stays off; in production there is a single reverse proxy, so we set it to 1. If you add a CDN in front, the number of proxies in the chain changes and this value must be updated accordingly.
One address: www to apex redirect and HSTS
If both the www and the bare form of your domain return the same content with status 200, search engines see two copies of every page. In production there should be exactly one address, and the other should answer with a permanent redirect (301). You can do this at the proxy level; we chose to do it inside the application with a single hook, because the rule is then versioned and tested together with the code:
from flask import redirect, request
SITE_URL = os.environ.get("SITE_URL", "https://example.com").rstrip("/")
@app.before_request
def www_to_apex():
host = request.host.split(":")[0].lower()
if host.startswith("www."):
return redirect(SITE_URL + request.full_path.rstrip("?"), code=301)
request.full_path appends a lone ? when the query string is empty, which is why rstrip("?") is there. Building the target from the configured address rather than from the request's Host header is deliberate: a request with a tampered header cannot turn the redirect towards another domain.
To tell browsers that HTTPS is permanent we use the Strict-Transport-Security header. It should only be sent on responses delivered over HTTPS. Behind a proxy, X-Forwarded-Proto tells you whether the original request really arrived over HTTPS:
@app.after_request
def security_headers(response):
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
proto = request.headers.get("X-Forwarded-Proto", "").split(",")[0].strip()
if request.is_secure or proto == "https":
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
return response
Before adding includeSubDomains, make sure every subdomain you own works over HTTPS; browsers remember this header for a year and it is not easy to take back. We cover the next step in security headers, Content Security Policy, in a separate article.
Environment variables and SECRET_KEY
Passwords, API keys and the session signing key never go into the repository; they are defined as the application's environment variables in Coolify, which passes them to the container at run time. The variable most often overlooked is Flask's SECRET_KEY. Flask signs the session cookie with it; if the key stays at a well-known default, an attacker can forge a valid administrator session cookie on their own.
We close that risk with code rather than with a promise to remember. In production, if the key still has its default value, logging in to the admin panel is disabled entirely and our internal monitoring endpoint shows it as a warning:
DEV_KEY = "gelistirme-anahtari-degistir"
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", DEV_KEY)
app.config["DEFAULT_SECRET_IN_PROD"] = (
app.config["SECRET_KEY"] == DEV_KEY
and os.environ.get("FLASK_ENV") == "production"
)
def is_admin() -> bool:
# never trust a session signed with the default key
return not app.config["DEFAULT_SECRET_IN_PROD"] and bool(session.get("admin"))
To generate a long, random key, python -c "import secrets; print(secrets.token_hex(32))" is enough. Changing the key invalidates every open session; that is usually what you want, but it should be done knowingly.
SQLite, a persistent volume and verified backups
Containers are disposable: every deployment creates a new container, and files written inside the old one are gone. The SQLite file that stores requests from our contact form therefore lives not inside the container but on a persistent volume defined in Coolify. The VOLUME ["/app/instance"] line in the Dockerfile documents that this folder must persist; the actual mount is configured in the panel as persistent storage pointing at the same path. Forgetting that step means every record silently disappears on the first redeploy.
A persistent volume is not a backup. The file can become corrupted or be deleted by mistake. So the application takes its own backup once a day, and it does not stop at taking it: it restores and verifies it. SQLite's online backup API produces a consistent copy while the application keeps running:
import sqlite3
from contextlib import closing
from pathlib import Path
def backup_and_verify(source: Path, target: Path) -> None:
uri = source.resolve().as_uri() + "?mode=ro" # open the source read-only
with closing(sqlite3.connect(uri, uri=True, timeout=30)) as src, \
closing(sqlite3.connect(target)) as dst:
src.backup(dst)
with closing(sqlite3.connect(target)) as dst:
result = dst.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
target.unlink(missing_ok=True)
raise RuntimeError(f"backup is corrupt: {result}")
Our real version does a little more: it restores the copy into a separate temporary file and compares table and row counts with the source, stores a SHA-256 digest of every copy, keeps the last fourteen verified copies, and does not delete older copies if a table suddenly drops below half its size. There is also an honest limit: these backups sit on the same disk as the database. They protect against corruption and accidental deletion, not against losing the whole disk; an off-site copy is a separate decision.
Gunicorn workers and in-process state
Running gunicorn with two workers means two separate Python processes share the requests. Anything kept in memory is per worker. The login throttle on the admin panel is a typical example: we count failed attempts in an in-memory dictionary, so each worker keeps its own counter. In practice the limit is loosened by a factor equal to the number of workers. For a small site that is an acceptable trade-off; if the limit must be exact, the counter belongs in the database or in a shared cache.
The same reasoning applies to background jobs. The backup loop starts as a separate thread in each worker. To stop two workers from taking a backup at the same moment we use a lock file and add a random delay of a few seconds at start-up. In a larger system it is cleaner to move such jobs to a separate scheduled task; for a single-container site, a lock file is enough.
Health checks and deploying from Git
Once connected to the repository, Coolify can be configured to start a new deployment whenever you push to the chosen branch. A new image is built, the container starts and traffic is switched to it. For that flow to be reliable, the platform must be able to tell when the new container is actually ready. That is what a health check is for: the platform requests a given path and expects a successful response.
Using the home page as the health check works, but it is not ideal; the home page can get heavy or return 503 in maintenance mode. A separate, lightweight endpoint is clearer. We have not added one to this site yet; the shape we use in other products looks like this:
@app.get("/health")
def health():
try:
with closing(sqlite3.connect(app.config["DATABASE"], timeout=2)) as con:
con.execute("SELECT 1")
except sqlite3.Error:
return {"status": "database"}, 503
return {"status": "ok"}, 200
Running the tests locally before deploying is what makes push-to-production safe. We run the full test suite on every change before pushing, and after going live the first thing we check is the status code of the home page, the sitemap and a few critical pages.
Conclusion
Publishing on your own server means pairing the convenience of a panel with correct behaviour in your code. A plain Dockerfile, a ProxyFix with a deliberately chosen proxy count, a redirect to a single address, HSTS sent only over HTTPS, a secret key that refuses to run on its default, a database mounted on a persistent volume and backed up with verification: each of these is a handful of lines, but together they prevent most of the silent failures that appear after launch. You can read how our studio works on our about page, and you are welcome to write to us about your own project.
Building something similar?
Describe what you need in a few sentences; if there is a good fit, we will work out the details together.
Write to us