Deploying a Docker Compose Stack to a Vultr VPS with Nginx and HTTPS
So you’ve built an awesome web project with Flask (your “web” frontend), a FastAPI backend (your “api”), a Redis database, and even a Celery worker, all neatly orchestrated with Docker Compose. That’s fantastic! Now, the exciting part: taking it live for the world to see.
While managed services like Google Cloud Run offer simplicity, sometimes you need more control, a predictable monthly cost for consistent usage, or simply enjoy getting your hands dirty with server administration. That’s where a Virtual Private Server (VPS) like comes in.
A VPS deployment is a single-host production setup where you own the whole stack — operating system, container runtime, reverse proxy, and TLS certificates — in exchange for a fixed monthly price rather than per-request billing. This post walks you step by step through deploying a Docker Compose project to a Vultr VPS, with Nginx as the reverse proxy and HTTPS via Certbot and Let’s Encrypt.
Project Structure: Flask, FastAPI, Redis and Celery in Docker Compose
The docker-compose.yml File
Before we dive in, here is my docker-compose.yml structure:
services:
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- shared_data:/data
networks:
- deployml_network
# ------------ FastAPI + Gunicorn + Uvicorn ------------
api:
build: ./api # your Dockerfile lives here
restart: always
ports:
- "127.0.0.1:5005:80" # host:container -- bound to loopback so only Nginx can reach it
environment:
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
- redis
networks:
- deployml_network
# ------------ Celery worker ---------------------------
worker:
build: ./api # reuse the same image as the API
command: >
celery -A main.celery_app worker --loglevel=info
environment:
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
- redis
networks:
- deployml_network
# ------------ Your existing Flask "web" frontend ------
web:
build: ./web/
ports:
- "127.0.0.1:8080:80" # host:container -- Nginx owns host port 80, so publish the app on 8080, loopback only
volumes:
- shared_data:/app/data
entrypoint: ["/app/entrypoint.sh"]
command: ["gunicorn", "-w", "4", "--bind", "0.0.0.0:80", "app:app"]
environment:
- FLASK_DEBUG=0 # never 1 on a public host
- FLASK_APP=./app.py
depends_on:
- redis
- api
networks:
- deployml_network
deploy:
resources:
limits:
memory: "24g" # Note: This `deploy` block is for Swarm, not used by plain Docker Compose on a single host.
# --------------------------------------------------------
volumes:
shared_data:
networks:
deployml_network:
As you see, the stack is not just a web app: there is a FastAPI backend, a Celery worker, and a Redis instance acting as both broker and result store. Yours might look different, and that is fine. This is the shape I keep coming back to, and it works marvellously.
Three settings in that file are worth spelling out, because getting them wrong costs you an evening:
FLASK_DEBUG=0, always, on a public host. Flask’s debugger exposes an interactive Python console on any traceback page. WithFLASK_DEBUG=1on a reachable server, an unhandled exception hands a stranger remote code execution — the Flask documentation is blunt about this under debug mode. Set it to0in the Compose file and never rely on remembering to change it later.-
-w 4Gunicorn workers, not 21. The Gunicorn design notes suggest(2 × CPU cores) + 1as a starting point, which on the 2-vCPU instance this post recommends is five, not twenty-one. Each worker is a full copy of your app in memory; over-provisioning them on a small VPS buys you swap thrashing rather than throughput. webpublishes on host port 8080, not 80. Nginx is going to bind host port 80 in Step 6. Two processes cannot bind the same host port, so if you leavewebon"80:80", Nginx fails to start withbind() to 0.0.0.0:80 failed (98: Address already in use). Publish the app somewhere else and let the reverse proxy own 80.- Both published ports are prefixed with
127.0.0.1:. Without that prefix, Docker publishes on all interfaces and — critically — writes its owniptablesDOCKER chain rules that are evaluated before yourufwrules, so a host firewall will not save you. Binding to loopback means only Nginx, running on the same box, can reach the containers. The Docker documentation covers this behaviour under packet filtering and firewalls, and it surprises more people than it should.
Project File Layout
This structure will give you a runnable foundation for your Flask web frontend, FastAPI backend, Redis database, and Celery worker.
Project Structure Overview as follows:
my-deployml-app/
├── docker-compose.yml
├── .env # Environment variables (e.g., FLASK_DEBUG, SECRET_KEY)
├── api/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── main.py # FastAPI app and Celery tasks
├── web/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── app.py # Flask app
│ └── entrypoint.sh # Web app entrypoint script
Here’s a breakdown of the files and what belongs in each, designed to give you a minimal yet functional example of the stack.
1. docker-compose.yml — orchestrates all four services, as above.
2. .env — environment variables your services need. Useful for anything sensitive, and for configuration that differs between development and production. Add .env to your .gitignore before you write a single secret into it.
# my-deployml-app/.env
# Never commit this file in a production environment.
FLASK_DEBUG=0 # 0 in production
FLASK_SECRET_KEY=supersecretkeyforexample # replace with a real random value
Generate that secret key properly rather than typing something memorable: python -c "import secrets; print(secrets.token_hex(32))" gives you 64 hex characters from the operating system’s cryptographically secure source, which is what secrets exists for.
3. api/ — the FastAPI application, its requirements.txt, and the Dockerfile. The same image serves both the api and worker services; docker-compose.yml overrides the command for the worker, so the Dockerfile only needs a sensible default:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Overridden by the `worker` service in docker-compose.yml
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:80", "main:app"]
4. web/ — the Flask frontend, its requirements.txt, its Dockerfile, and entrypoint.sh.
Prerequisites
- A Vultr Account: If you don’t have one, sign up at .
- A Domain Name: Registered with a provider like GoDaddy.
- SSH Client: For Windows, PuTTY or Git Bash; for macOS/Linux, your built-in terminal.
- Git: Installed on your local machine to manage your code.
- Your Project on GitHub (or similar): Your application code should be in a Git repository accessible from your server.
Deployment Walkthrough: From Blank VPS to HTTPS
Step 1: Launch Your Vultr Compute Instance
First, we need a server.
- Log in to Vultr: Go to your Vultr dashboard.
- Deploy New Server: Click the blue “Deploy New Server” button.
- Choose Server Type:
- Select Cloud Compute. For a project with Flask, FastAPI, Redis, and a worker, starting with a High Frequency plan is recommended for better performance (e.g., 2GB or 4GB RAM). Choose a size that provides at least 2GB RAM (4GB is safer for a multi-service app) and sufficient storage (e.g., 50GB NVMe).
- Choose Server Location: Select a data center geographically closest to your target users for optimal latency.
- Choose Server Image:
- Select the OS tab.
- Choose Ubuntu LTS (Ubuntu 24.04 LTS or the newer 26.04 LTS). LTS (Long Term Support) versions are stable and receive updates for several years — I’d skip 22.04 for a fresh deploy at this point, since it’s now the older of the two supported LTS releases.
- SSH Keys:
- Crucial for Security! Generate an SSH key pair on your local machine if you haven’t already (
ssh-keygen -t rsa -b 4096). - Add your public SSH key to Vultr by clicking “Add New” under “SSH Keys”. Give it a descriptive name. This allows you to securely log in without a password.
- Crucial for Security! Generate an SSH key pair on your local machine if you haven’t already (
- Firewall Group:
- Click “Manage” next to “Firewall Group”.
- Create a new firewall group.
- Add rules to allow:
- SSH (Port 22): For you to connect.
- HTTP (Port 80): For your web traffic.
- HTTPS (Port 443): For secure web traffic.
- Save the firewall group and apply it to your server during deployment.
- Server Hostname & Label: Give your server a memorable hostname (e.g.,
your-app-server) and label. - Deploy Now: Click “Deploy Now.” Your server will be provisioned in minutes.
Once deployed, note down your server’s IP Address from the Vultr dashboard.
Step 2: Connect to Your Server via SSH
Open your terminal (or PuTTY/Git Bash) and connect to your server. Replace YOUR_SERVER_IP with the IP address you noted.
ssh root@YOUR_SERVER_IP
If this is your first time connecting, you might be asked to confirm the authenticity of the host. Type yes and press Enter.
Step 3: Install Docker and Docker Compose
Now, let’s get Docker running on your fresh server.
-
Update System Packages:
sudo apt update sudo apt upgrade -y -
Install Docker Engine & Docker Compose Plugin: It’s best to install Docker directly from Docker’s official repository to get the latest stable version.
# Add Docker's official GPG key sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc # Add Docker repository to APT sources sudo tee /etc/apt/sources.list.d/docker.sources <<EOF Types: deb URIs: https://download.docker.com/linux/ubuntu Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") Components: stable Architectures: $(dpkg --print-architecture) Signed-By: /etc/apt/keyrings/docker.asc EOF sudo apt update # Install Docker Engine, CLI, containerd, and Docker Compose plugin sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin(Docker moved to this
.sources/deb822 format — GPG key saved straight asdocker.asc, no moregpg --dearmor— a little while back. The olderdocker.list+signed-by=one-liner you’ll see in older tutorials still works, but this is whatdocs.docker.comships today.) -
Add Your User to the
dockerGroup: This allows you to run Docker commands withoutsudo.sudo usermod -aG docker ${USER}Important: For this change to take effect, you need to log out and log back into your SSH session. You can do this by typing
exitand thenssh root@YOUR_SERVER_IPagain. -
Verify Installation:
docker --version docker compose versionYou should see version information for both.
Step 4: Get Your Project Code
We need your project files on the server. The easiest way is to clone your Git repository.
-
Install Git (if not already present):
sudo apt install git -y -
Clone Your Repository: Navigate to a suitable directory (e.g.,
/opt/). Replaceyour-username/your-repowith your actual GitHub path.cd /opt/ git clone https://github.com/your-username/your-repo.git cd your-repo # Navigate into your project directorySecurity Note: If your repository is private, you’ll need to set up SSH keys for Git on your server or use a deploy key.
Step 5: Run Your Docker Compose Project
Now, let’s bring your application to life!
-
Create
.envFile (Optional but Recommended): If yourdocker-compose.ymlor application code relies on environment variables (e.g., for database credentials, API keys,FLASK_ENV), create a.envfile in your project root directory (the same one asdocker-compose.yml).nano .envAdd your variables:
FLASK_DEBUG=0 # Set to 0 for production! SECRET_KEY=YOUR_SUPER_SECRET_KEY # ... other variablesSave and exit (
Ctrl+X,Y, Enter). Make sure yourdocker-compose.ymlreferences these (e.g.,env_file: ./.env). -
Build and Start Services: From your project root directory (where
docker-compose.ymlresides):docker compose up -d --buildup: Starts the services defined in yourdocker-compose.yml.-d: Runs containers in “detached” mode (in the background).--build: Forces Docker Compose to rebuild images. This is essential for the first deploy and whenever yourDockerfiles or build context change.
-
Verify Containers: Check if all your containers are running:
docker psYou should see
redis,api,worker, andwebcontainers listed as “Up”. -
Check Logs (if issues): If something isn’t running, check the logs for specific services:
docker compose logs api docker compose logs web # etc.
At this point, your services are running inside Docker containers on your Vultr server. Your Flask web container is exposed on port 80 of the host, and your FastAPI api container is exposed on port 5005 of the host. You could theoretically access them via http://YOUR_SERVER_IP/ and http://YOUR_SERVER_IP:5005/, but this is not how we’ll serve them in production. We’ll use Nginx.
Step 6: Configure Nginx as a Reverse Proxy
Nginx will be the public-facing entry point for your application. It will handle incoming web requests and route them to the correct Docker container.
-
Install Nginx:
sudo apt install nginx -y -
Create Nginx Configuration File: We’ll create a new configuration file for your domain. Replace
your_domain.comwith your actual domain.sudo nano /etc/nginx/sites-available/your_domain.comPaste the following configuration. This sets up two locations:
/for your Flask web app and/apifor your FastAPI backend.server { listen 80; listen [::]:80; server_name your_domain.com www.your_domain.com; # IMPORTANT: Replace with your domain # Proxy requests to your Flask web app (published on host port 8080) location / { proxy_pass http://127.0.0.1:8080; # Flask web app, loopback-only proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Optional: Larger client body size for file uploads client_max_body_size 100M; } # Proxy requests to your FastAPI backend (published on host port 5005) location /api { proxy_pass http://127.0.0.1:5005; # FastAPI backend, loopback-only proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Optional: Larger client body size for file uploads client_max_body_size 100M; } }Save and exit (
Ctrl+X,Y, Enter).Two things about
location /apithat bite people later. Becauseproxy_passhere has no trailing slash, Nginx forwards the full original path — a request for/api/tasksarrives at FastAPI as/api/tasks, not/tasks. Either mount your FastAPI routes under an/apiprefix (APIRouter(prefix="/api")), or add the trailing slash —proxy_pass http://127.0.0.1:5005/;— to strip it. The Nginxproxy_passdocumentation spells out the difference, and it is the single most common reverse-proxy 404 I have debugged. -
Enable the Configuration: Create a symbolic link from
sites-availabletosites-enabled, and remove the packaged default site so it does not shadow yourserverblock on requests that arrive without a matchingHostheader.sudo ln -s /etc/nginx/sites-available/your_domain.com /etc/nginx/sites-enabled/ sudo rm -f /etc/nginx/sites-enabled/default -
Test Nginx Configuration:
sudo nginx -tYou should see
syntax is okandtest is successful. If there are errors, fix them in your Nginx config file. -
Restart Nginx:
sudo systemctl restart nginx
Step 7: Configure Your Domain with GoDaddy (or Your DNS Provider)
Now, you need to tell your domain name to point to your Vultr server’s IP address.
-
Log in to GoDaddy: Access your domain management portal.
-
Go to DNS Settings: Find the DNS management section for your domain.
-
Add/Edit A Records:
- Create an
Arecord for your root domain (@or leave blank) pointing to your Vultr server’s IP address. - Create another
Arecord forwwwpointing to the same Vultr server’s IP address.
Type Name Value TTL A @ YOUR_SERVER_IP 600 seconds A www YOUR_SERVER_IP 600 seconds - Create an
-
Save Changes: Allow some time for DNS propagation (this can take from a few minutes to a few hours, though usually it’s quick).
Once propagation is complete, you should be able to access your site via http://your_domain.com/ and see your Flask app, and http://your_domain.com/api/ should hit your FastAPI backend.
Step 8: Secure Your Site with HTTPS (Certbot/Let’s Encrypt)
HTTPS is essential for security, SEO, and user trust. Let’s Encrypt provides free SSL certificates, and Certbot automates the process.
-
Install Certbot: Certbot is best installed via
snapfor up-to-date versions.sudo snap install core sudo snap refresh core sudo snap install --classic certbot sudo ln -s /snap/bin/certbot /usr/local/bin/certbot -
Obtain SSL Certificate and Configure Nginx: Certbot’s Nginx plugin will automatically configure Nginx for HTTPS.
sudo certbot --nginx -d your_domain.com -d www.your_domain.comFollow the prompts:
- Enter your email address for urgent renewal notices.
- Agree to the terms of service.
- Choose whether to redirect HTTP traffic to HTTPS (highly recommended: select
2for redirect).
Certbot will automatically modify your Nginx configuration, add the SSL certificate, and set up automatic renewals.
-
Test Renewal (Optional):
sudo certbot renew --dry-runThis command simulates the renewal process to ensure it’s working.
Now, your website should be accessible via https://your_domain.com/!
Production Hardening: Firewall, Persistence, Monitoring, Backups and Scaling
-
Security:
- Firewall: Keep your Vultr firewall tight. Only open ports 22 (SSH), 80 (HTTP), and 443 (HTTPS) to the public. Internal container communication happens within the Docker network.
- SSH Security: Consider disabling password authentication for SSH and only allowing key-based authentication. You’ve already set up SSH keys, which is a great start.
- Regular Updates: Keep your OS and Docker packages updated (
sudo apt update && sudo apt upgrade -y). - Sensitive Data: Never hardcode sensitive information (API keys, database passwords) directly in your code or
docker-compose.yml. Use environment variables loaded from a.envfile (as shown) or a more robust secret management solution for larger projects.
-
Persistence for Redis & Data: Your
docker-compose.ymluses ashared_datavolume. This is good for Redis persistence. Ensure any other data yourweborapicontainers write to/app/datais also persistent through volumes. Docker managed volumes are stored on the host’s filesystem, so they persist even if the container is removed. -
Monitoring:
- Vultr Monitoring: Use Vultr’s built-in monitoring to keep an eye on CPU, memory, and network usage.
- Docker Logs: Regularly check your Docker Compose logs (
docker compose logs -f) to catch application errors. - Health Checks: For production, consider adding health checks to your Docker Compose services so Docker knows if a container is actually ready to serve requests.
-
Backups:
- Vultr Snapshots: Vultr offers snapshot backups of your entire server, which are great for disaster recovery.
- Database Backups: For production databases (even Redis), implement a proper backup strategy that regularly copies your data off-server.
-
Scaling:
- This setup is for a single server. If your app grows significantly, you might need to:
- Scale up your Vultr instance (more RAM/CPU).
- Consider a database service separate from your VPS (e.g., Vultr Managed Database for Redis).
- Eventually, move to a more complex orchestration system like Docker Swarm or Kubernetes, or cloud-managed services that handle scaling automatically.
- This setup is for a single server. If your app grows significantly, you might need to:
Pre-Launch Operations Checklist for a Docker Compose VPS
All of the above, condensed into the five things I actually check before pointing a domain at a fresh box:
- Only 80, 443, and 22 are open on the Vultr firewall group — nothing else.
sudo certbot renew --dry-runsucceeds, so you know renewal will actually work in three months’ time, not just today.- Log rotation is configured and you’d get an alert before the disk fills, not after.
- Backups exist for anything stateful (your database, your
shared_datavolume) — and you’ve actually run a restore once, not just a backup. - Every service has a sane
restartpolicy and, ideally, ahealthcheck:block, so a crashed container comes back on its own instead of paging you at 2am.
Final Thoughts
Your Docker Compose project now runs on a Vultr VPS behind Nginx, with a Let’s Encrypt certificate renewing itself in the background. It is a robust, cost-predictable, fully controlled environment — and, unlike a managed platform, entirely your problem when it breaks.
That trade is the whole point. You pay in hands-on management and get flexibility plus a bill that does not surprise you. The two things I would not skip, if you skip anything: bind your published container ports to 127.0.0.1 so the reverse proxy is genuinely the only way in, and actually restore a backup once before you need to. Happy deploying.
If this setup held up well enough that you’d point a colleague or client at it too, runs its own referral program for exactly that.
References
4. Docker Engine install instructions for Ubuntu
6. Docker: packet filtering and firewalls — why published ports bypass ufw
7. Nginx: proxy_pass directive and the trailing-slash rule
8. Nginx: ngx_http_upstream_module for health checks and multiple backends
9. Docker Compose: the healthcheck and restart service options
10. Python secrets module — generating a real FLASK_SECRET_KEY
Stay Ahead in AI, Machine Learning & Python
No hype. Weekly notes on AI tools, Python, and what I'm actually building — plus six free gifts, including the 15-page Fantastic AI: The 2026 Toolkit and a Git Commands & Contribution Workflow Cheatsheet.
You're in
Check your inbox for Set a password to unlock articles if you want gated tutorials. Log in with the same email.