Elena' s AI Blog

From Localhost to Live

21 Sep 2026 (updated: 21 Sep 2026) / 27 minutes to read

Elena Daehnhardt

Matrix fell, Midjourney Nov 2023


TL;DR:
  • Deploy Compose apps safely on a VPS with reverse proxy, TLS, restricted ports, and automated restart/backup policies.

Previous: Part 3 — Docker Compose with Flask and Redis: A Working Two-Container Example (and a LlamaGPT Case Study)

Next: Part 5 — Caching Docker Compose Builds with BuildKit

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. With FLASK_DEBUG=1 on a reachable server, an unhandled exception hands a stranger remote code execution — the Flask documentation is blunt about this under debug mode. Set it to 0 in the Compose file and never rely on remembering to change it later.
  • -w 4 Gunicorn workers, not 21. The Gunicorn design notes suggest (2 × CPU cores) + 1 as 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.

  • web publishes 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 leave web on "80:80", Nginx fails to start with bind() 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 own iptables DOCKER chain rules that are evaluated before your ufw rules, 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

  1. A Vultr Account: If you don’t have one, sign up at .
  2. A Domain Name: Registered with a provider like GoDaddy.
  3. SSH Client: For Windows, PuTTY or Git Bash; for macOS/Linux, your built-in terminal.
  4. Git: Installed on your local machine to manage your code.
  5. 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.

  1. Log in to Vultr: Go to your Vultr dashboard.
  2. Deploy New Server: Click the blue “Deploy New Server” button.
  3. 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).
  4. Choose Server Location: Select a data center geographically closest to your target users for optimal latency.
  5. 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.
  6. 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.
  7. 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.
  8. Server Hostname & Label: Give your server a memorable hostname (e.g., your-app-server) and label.
  9. 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.

  1. Update System Packages:

    sudo apt update
    sudo apt upgrade -y
    
  2. 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 as docker.asc, no more gpg --dearmor — a little while back. The older docker.list + signed-by= one-liner you’ll see in older tutorials still works, but this is what docs.docker.com ships today.)

  3. Add Your User to the docker Group: This allows you to run Docker commands without sudo.

    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 exit and then ssh root@YOUR_SERVER_IP again.

  4. Verify Installation:

    docker --version
    docker compose version
    

    You 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.

  1. Install Git (if not already present):

    sudo apt install git -y
    
  2. Clone Your Repository: Navigate to a suitable directory (e.g., /opt/). Replace your-username/your-repo with your actual GitHub path.

    cd /opt/
    git clone https://github.com/your-username/your-repo.git
    cd your-repo # Navigate into your project directory
    

    Security 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!

  1. Create .env File (Optional but Recommended): If your docker-compose.yml or application code relies on environment variables (e.g., for database credentials, API keys, FLASK_ENV), create a .env file in your project root directory (the same one as docker-compose.yml).

    nano .env
    

    Add your variables:

    FLASK_DEBUG=0 # Set to 0 for production!
    SECRET_KEY=YOUR_SUPER_SECRET_KEY
    # ... other variables
    

    Save and exit (Ctrl+X, Y, Enter). Make sure your docker-compose.yml references these (e.g., env_file: ./.env).

  2. Build and Start Services: From your project root directory (where docker-compose.yml resides):

    docker compose up -d --build
    
    • up: Starts the services defined in your docker-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 your Dockerfiles or build context change.
  3. Verify Containers: Check if all your containers are running:

    docker ps
    

    You should see redis, api, worker, and web containers listed as “Up”.

  4. 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.

  1. Install Nginx:

    sudo apt install nginx -y
    
  2. Create Nginx Configuration File: We’ll create a new configuration file for your domain. Replace your_domain.com with your actual domain.

    sudo nano /etc/nginx/sites-available/your_domain.com
    

    Paste the following configuration. This sets up two locations: / for your Flask web app and /api for 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 /api that bite people later. Because proxy_pass here has no trailing slash, Nginx forwards the full original path — a request for /api/tasks arrives at FastAPI as /api/tasks, not /tasks. Either mount your FastAPI routes under an /api prefix (APIRouter(prefix="/api")), or add the trailing slash — proxy_pass http://127.0.0.1:5005/; — to strip it. The Nginx proxy_pass documentation spells out the difference, and it is the single most common reverse-proxy 404 I have debugged.

  3. Enable the Configuration: Create a symbolic link from sites-available to sites-enabled, and remove the packaged default site so it does not shadow your server block on requests that arrive without a matching Host header.

    sudo ln -s /etc/nginx/sites-available/your_domain.com /etc/nginx/sites-enabled/
    sudo rm -f /etc/nginx/sites-enabled/default
    
  4. Test Nginx Configuration:

    sudo nginx -t
    

    You should see syntax is ok and test is successful. If there are errors, fix them in your Nginx config file.

  5. 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.

  1. Log in to GoDaddy: Access your domain management portal.

  2. Go to DNS Settings: Find the DNS management section for your domain.

  3. Add/Edit A Records:

    • Create an A record for your root domain (@ or leave blank) pointing to your Vultr server’s IP address.
    • Create another A record for www pointing 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
  4. 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.

  1. Install Certbot: Certbot is best installed via snap for 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
    
  2. 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.com
    

    Follow 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 2 for redirect).

    Certbot will automatically modify your Nginx configuration, add the SSL certificate, and set up automatic renewals.

  3. Test Renewal (Optional):

    sudo certbot renew --dry-run
    

    This 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

  1. 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 .env file (as shown) or a more robust secret management solution for larger projects.
  2. Persistence for Redis & Data: Your docker-compose.yml uses a shared_data volume. This is good for Redis persistence. Ensure any other data your web or api containers write to /app/data is also persistent through volumes. Docker managed volumes are stored on the host’s filesystem, so they persist even if the container is removed.

  3. 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.
  4. 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.
  5. 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.

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:

  1. Only 80, 443, and 22 are open on the Vultr firewall group — nothing else.
  2. sudo certbot renew --dry-run succeeds, so you know renewal will actually work in three months’ time, not just today.
  3. Log rotation is configured and you’d get an alert before the disk fills, not after.
  4. Backups exist for anything stateful (your database, your shared_data volume) — and you’ve actually run a restore once, not just a backup.
  5. Every service has a sane restart policy and, ideally, a healthcheck: 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

1. Vultr

2. Vultr referral program

3. Docker Compose overview

4. Docker Engine install instructions for Ubuntu

5. Certbot instructions

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

desktop bg dark

About Elena

Elena, a PhD in Computer Science, simplifies AI concepts and helps you use machine learning.

Citation
Elena Daehnhardt. (2026) 'From Localhost to Live', daehnhardt.com, 21 September 2026. Available at: https://daehnhardt.com/blog/2026/09/21/deploy-your-docker-compose-app-in-vultr/
All Posts