Deploy Flask to DigitalOcean
Follow this on your own server. Start free, no card needed
flask run is a development server by design, Flask’s own docs say so directly, and it isn’t meant to serve real traffic. Getting a Flask app into production means picking a WSGI server, pointing it at the right module, provisioning a database, and deciding how migrations run, decisions a minimal framework like Flask deliberately leaves to the app rather than assuming a single convention the way a larger framework might. This guide does all of that in Docker on a DigitalOcean Droplet, with Caddy handling HTTPS, and ends with a Flask app at your own domain you know how to redeploy.
What you’ll need
- A DigitalOcean Droplet. The Basic plan at 2 GB memory, 1 vCPU, 50 GB disk ($12/month) runs a single Flask app with Postgres alongside it comfortably; the 1 GB tier ($6/month) works for a low-traffic app without much headroom to spare.
- A domain, with DNS already pointed at the Droplet before requesting HTTPS.
- A DigitalOcean account.
- A Flask app in a Git repository. This guide uses Flask 3.1.3, which requires Python 3.9 or newer; the Dockerfile below pins Python 3.13.
- Docker and the Compose plugin, either locally to test the build first or directly on the Droplet.
Step 1: Create the Droplet
In the DigitalOcean control panel, Create → Droplets: pick Ubuntu 24.04, the region closest to your users, the Basic plan at 2 GB/1 vCPU, add your SSH key, and create it.
ssh root@your-droplet-ip
Step 2: Install Docker
curl -fsSL https://get.docker.com | sh
docker --version
docker compose version
Step 3: Write the Dockerfile
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "app:app"]
Flask’s own deployment docs point at gunicorn directly: “it tends to integrate easily with hosting platforms” and needs no extra dependencies to install. --workers 4 is a reasonable starting point (a common rule of thumb from gunicorn’s own docs is roughly 2 * CPU cores + 1); adjust it to the Droplet’s actual vCPU count. If your app uses an application factory instead of a module-level app variable, the command becomes gunicorn --bind 0.0.0.0:8000 'app:create_app()' instead.
A minimal requirements.txt:
flask>=3.1
gunicorn>=23.0
psycopg[binary]>=3.2
flask-sqlalchemy>=3.1
flask-migrate>=4.0
Step 4: Write the Compose file
services:
app:
build: .
restart: unless-stopped
env_file:
- .env
depends_on:
db:
condition: service_healthy
expose:
- "8000"
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 5
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy-data:/data
- caddy-config:/config
depends_on:
- app
volumes:
db-data:
caddy-data:
caddy-config:
Your .env file:
DB_NAME=app
DB_USER=app
DB_PASSWORD=change-this
SECRET_KEY=change-this-too
FLASK_ENV=production
DATABASE_URL=postgresql+psycopg://app:change-this@db:5432/app
Validate before starting:
docker compose config
Step 5: Write the Caddyfile
app.example.com {
reverse_proxy app:8000
}
Step 6: First boot and migrations
docker compose up -d
docker compose exec app flask db upgrade
docker compose ps
You should see app, db and caddy all running, and https://app.example.com should respond with a valid certificate.
Step 7: Deploy new versions
git pull
docker compose build app
docker compose up -d --no-deps app
docker compose exec app flask db upgrade
Rebuilding only app leaves db and caddy untouched, so the certificate and the database both stay up through the deploy.
Backups
docker compose exec -T db pg_dump -U app app | gzip > backup-$(date +%F).sql.gz
Copy the file off the Droplet on a cron job, and test a restore into a scratch database occasionally so you know it actually works.
Serving static files
Flask can serve its own static/ folder directly, but Caddy is already in front of every request and is better suited to it than asking gunicorn workers to spend time on file serving instead of handling application logic. Add a matcher ahead of the catch-all proxy:
app.example.com {
handle_path /static/* {
root * /srv/static
file_server
}
reverse_proxy app:8000
}
Mount the app’s static directory into a volume Caddy can also read, and run flask ... as whatever command your project uses to collect or copy static assets into that path during the Docker build, so the files exist before Caddy’s container starts looking for them.
The shortcut: do it with Railyard
Deploy Flask to DigitalOcean runs the same recipe from a repository URL. Connect a DigitalOcean account with a token that has Droplet write access, or bring a server you’ve already provisioned. Railyard detects Flask from requirements.txt or pyproject.toml, serves app:app on $PORT with gunicorn (pointed at your own module if it lives elsewhere), and recognizes psycopg as a dependency to provision Postgres with DATABASE_URL injected automatically. Set flask db upgrade or alembic upgrade head as the release command to migrate on every release, the same as Step 6 above but run automatically before each new version takes traffic. RQ is offered as a worker when it’s a dependency, and any other Procfile process becomes one too. See the Flask docs for the exact detection rules.
Troubleshooting
Connection refusedon first boot:appstarted before Postgres finished initializing; the healthcheck condition in Step 4 should prevent it, but a short retry loop around the first migration command is a reasonable extra safeguard.- gunicorn boots but requests hang: usually a worker count too high for the Droplet’s actual CPU, or a blocking call inside a request handler starving the other workers; check
docker statswhile under load and adjust--workersdown before adding a Droplet size instead. sqlalchemy.exc.OperationalErroreven though the database container is running: confirmDATABASE_URLin.envactually matches thedbcontainer’s credentials; a stale.envleft over from local development is the most common cause.- HTTPS never issues, Caddy keeps retrying: DNS isn’t pointing at the Droplet yet.
dig +short app.example.comshould match the Droplet’s IP. flask db upgradefails with “Can’t locate revision”: themigrations/folder wasn’t committed to the repository, or the image was built from a stale checkout missing a recent migration file; confirmgit statusis clean on the server before rebuilding.- Static assets return 404 through Caddy but work when hitting gunicorn directly: the
handle_pathmatcher in the static-files section above didn’t match the request path, usually because the app’s actual static URL prefix differs from/static/; check what prefix the app itself generates in rendered HTML before assuming Caddy is misconfigured.
FAQ
Why gunicorn instead of Flask’s built-in server? Flask’s own docs are explicit that the development server isn’t designed to be particularly secure, stable or efficient; gunicorn is a production WSGI server built for exactly this, with no extra dependencies to compile.
What about async Flask routes? Flask supports async def view functions since Flask 2.0, but gunicorn’s default sync worker doesn’t run an event loop; if the app genuinely needs async request handling at scale, gunicorn’s gevent worker class or switching to an ASGI-based framework are the two real options, not something this guide’s default worker handles well.
Postgres or MySQL? Flask-SQLAlchemy is database-agnostic; this guide uses Postgres, but swapping the db image and the SQLAlchemy connection URI’s scheme works the same way for MySQL.
Do I really need a 2 GB Droplet? A 1 GB Droplet runs Flask and Postgres together, but with very little memory headroom for traffic spikes or a second process; 2 GB is a more realistic baseline the moment real users show up.
What if my app needs a background worker, not just the web process? Add a Procfile-style process (worker: rq worker --url $REDIS_URL for example) as its own service in the Compose file, built from the same image as app, with its own command override, rather than running it inside the same container as the web process.
How do I see logs and basic metrics without SSHing in every time? DigitalOcean’s own Droplet dashboard shows CPU, memory and disk graphs out of the box with no extra setup; for application logs, docker compose logs -f app over SSH remains the most direct option unless you forward them to an external log aggregator, which is worth doing once more than one person needs to check logs regularly.
A Flask app on a DigitalOcean Droplet you provisioned yourself costs $12 a month, with gunicorn, Postgres and migrations explicit in files you control instead of assumed. Shipping a new release after they exist is a rebuild of one container and a migration command, nothing else to remember.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.