Hey folks! Today, I'm here to guide you through the process of deploying your Reflex app to DigitalOcean. We'll be leveraging Docker containers and setting up a robust CI/CD pipeline with GitHub Actions. This approach allows you to use DigitalOcean's powerful infrastructure while maintaining a streamlined deployment process. It's an excellent way to integrate your development workflow with automatic deployments, enabling you to focus more on coding and less on manual deployment tasks. Let's dive in!

Background

Before we go into the DigitalOcean-specific deployment process, it's crucial to have a solid understanding of deploying Reflex apps in general. We covered the essentials in our previous post on [running Reflex apps in production], which talks about the setup and what you'll need for deployment.

If you missed that one, take a moment to check it out. I'll grab a coffee in the meantime and see you back here in a bit!

What You Need to Know First

DigitalOcean offers various deployment solutions, but for this tutorial, we'll focus on creating a droplet and deploying a Docker container to it. Our Docker image will be recreated and redeployed each time we update our code, ensuring our app stays up-to-date.

Essentially, we'll be deploying a Docker container on a DigitalOcean droplet running Docker. This approach provides flexibility and scalability, making it easier to manage your application as it grows.

This guide should also be working for any other deployment to vps or any machines you can SSH into it. You would only need to change the place/container registry where to upload the image:

  • docker hub itself
  • github
  • ...or host your own registry which is also easy to setup as a container.

Deployment Prep

To follow along, you'll need two accounts:

  • GitHub
  • DigitalOcean

For DigitalOcean, the free account is sufficient to start. GitHub's free version should work fine for our purposes as well, as it includes GitHub Actions for public repositories.

I'm assuming you've already created a GitHub repository for your Reflex application and pushed your code there. If not, make sure to do that first.

We also need a working Dockerfile for our app. We'll use the official Reflex Dockerfile as our starting point. Here's the Dockerfile with explanations for each section:

# This Dockerfile is used to deploy a single-container Reflex app instance
# to services like Render, Railway, Heroku, GCP, and others.

# It uses a reverse proxy to serve the frontend statically and proxy to backend
# from a single exposed port, expecting TLS termination to be handled at the
# edge by the given platform.
FROM python:3.11.3-slim-buster

# If the service expects a different port, provide it here (f.e Render expects port 10000)
ARG PORT=8080
# Only set for local/direct access. When TLS is used, the API_URL is assumed to be the same as the frontend.
ARG API_URL
ENV PORT=$PORT API_URL=${API_URL:-http://localhost:$PORT}

# Install Caddy server inside image
RUN apt-get update -y && apt-get install -y caddy && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Create a simple Caddyfile to serve as reverse proxy
RUN cat > Caddyfile <<EOF
:{\$PORT}

encode gzip

@backend_routes path /_event/* /ping /_upload /_upload/*
handle @backend_routes {
	reverse_proxy localhost:8000
}

root * /srv
route {
	try_files {path} {path}/ /404.html
	file_server
}
EOF

# Copy local context to `/app` inside container (see .dockerignore)
COPY . .

# Install app requirements and reflex in the container
RUN pip install -r requirements.txt

# Deploy templates and prepare app
RUN reflex init

# Download all npm dependencies and compile frontend
RUN reflex export --frontend-only --no-zip && mv .web/_static/* /srv/ && rm -rf .web

# Needed until Reflex properly passes SIGTERM on backend.
STOPSIGNAL SIGKILL

EXPOSE $PORT

# Apply migrations before starting the backend.
CMD [ -d alembic ] && reflex db migrate; \
    caddy start && reflex run --env prod --backend-only --loglevel debug

This Dockerfile sets up a Python environment, installs the Caddy server for reverse proxying, copies your application files, installs dependencies, and prepares your Reflex app for deployment.

Setting Up DigitalOcean

Next, we need to take care of some DigitalOcean specifics. We'll go through the following steps:

  1. Create an access token to interact with the API
  2. Create a droplet that will run our application
  3. Create a container registry to store our container image

Create an Access Token to Interact with the API

First, to interact with the DigitalOcean API, you need to create a new token:

  1. Go to your project in DigitalOcean (or create a new project)
  2. Navigate to API > Generate new Token
  3. Give it a descriptive name and set an expiration date (or leave it as never expires if you prefer)
  4. Make a copy of the token - you'll need it to create a secret in your GitHub repository

This token is crucial as it allows the GitHub Actions runners to push and pull images to and from your DigitalOcean container registry.

At the end I went to set full access for the token, as it was hard to figure out which minimal set is enough to get things rolling.

Create a Droplet to Run Your Application

Once you've created a droplet (Ubuntu is recommended, min 1GB RAM), you'll need to install Docker:

  1. Follow DigitalOcean's guide to install Docker on Ubuntu
  2. For security reasons, we'll create a new user which will be used by the GitHub Action

Let's set up SSH access on the DigitalOcean server for GitHub Actions:

First, set up a user for GitHub Actions on the server. We'll use root to login to the remote server initially, but it's better for GitHub Actions to run things without root privileges:

ssh root@<ip.addr.of.droplet>
useradd -m ghactions

This creates our user without a password prompt. The only way to access the server with this user will be through SSH keys.

Because the user will likely need to pull Docker images, add it to the Docker user group. This bypasses the requirement for sudo when running Docker commands:

sudo usermod -aG docker ghactions
newgrp docker

Next, create SSH keys for the ghactions user on your local computer. We don't want to use personal SSH keys, so generate the keys in a temporary location on your local machine:

ssh-keygen -C ghactions -f /tmp/ghactions-keys

Now, upload the public key to the server:

  1. Log in as root:

    ssh root@<ip.addr.of.droplet>
    
  2. Create the authorized_keys file for the ghactions user and adjust ownership:

    mkdir -p /home/ghactions/.ssh
    touch /home/ghactions/.ssh/authorized_keys
    chown ghactions:ghactions -R /home/ghactions/.ssh
    chmod 700 -R /home/ghactions/.ssh
    
  3. Copy-paste the public key from your local computer to the remote machine:

    # On your local machine
    cat /tmp/ghactions-keys.pub
    
    # On the remote machine
    nano /home/ghactions/.ssh/authorized_keys
    
  4. Disconnect from the server (exit or Ctrl+D)

  5. Test the connection and Docker access:

    ssh -i /tmp/ghactions-keys ghactions@<ip.addr.of.droplet>
    docker run hello-world
    

If these steps are successful, you're ready to move on to the next stage.

It is recommended also to create/activate a swap file by following the instructions - this makes sure that your app loads quick and snappy.

Create a Container Registry to Store Your Container Image

  1. In the DigitalOcean Dashboard, select "Container Registry" and create a starter (free) version
  2. Note the registry endpoint (e.g., registry.digitalocean.com/your-project-name)

We'll use this registry to store our Docker images. In our automation workflow, we'll always clean up the image we just built to save space.

Configuring GitHub

In your GitHub repository, you need to set up these secrets for building the app:

  • DIGITALOCEAN_ACCESS_TOKEN: The access token you created earlier
  • DO_HOST: The IP address of your DigitalOcean droplet
  • DO_SSH_PRIVATE_KEY: The private key generated for the ghactions user

To add these secrets:

  1. Go to your GitHub repository
  2. Navigate to Settings > Secrets and variables > Actions
  3. Click on "New repository secret" for each secret you need to add

Here's what your GitHub secrets should look like:

Automating the Deployment

Now, let's set up the GitHub Actions workflow that will handle the deployment process. This workflow is fairly straightforward, mainly involving Docker operations to build, push, and pull images. We'll use the appleboy/[email protected] to access the DigitalOcean droplet via SSH.

Here's a breakdown of what the workflow does:

  1. Trigger: The workflow starts whenever someone pushes code to the main branch or manually triggers it.

  2. Build and Push:

    • It builds a Docker image of your app, giving it a unique tag based on the commit.
    • It slims down your docker image using docker-slim. This step is optional and especially useful when you are on free docker registry plan of DigitialOcean.
    • It logs into your DigitalOcean Container Registry.
    • It pushes the newly built image to this registry.
  3. Deploy:

    • It connects to your DigitalOcean droplet using SSH.
    • It pulls the new Docker image onto the droplet.
    • It stops and removes any old version of your app that might be running.
    • It starts a new container using the fresh image, making your app accessible on port 80.
  4. Clean Up (optional):

    • After successful deployment, it removes the specific image tag from the registry to save space.

Create a new file at .github/workflows/deploy.yml in your repository with the following content:

name: Deploy to DigitalOcean

on:
  push:
    branches: [main]
  workflow_dispatch:

env:
  REGISTRY: "registry.digitalocean.com/geniepy"  # change accordingly!
  IMAGE_NAME: "demo-app" # change accordingly!

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
    - name: Checkout files
      uses: actions/checkout@v3

    - name: Build container image latest version
      run: |
        docker build \
          --build-arg YOUR_SECERET1=${{ secrets.YOUR_SECERET1 }} \  # in case you have any Secrets you need to pass through
          -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .

    - name: Slim down the container image
      uses: kitabisa/docker-slim-action@v1
      env:
        DSLIM_HTTP_PROBE: false
      with:
        target: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
        tag: ${{ github.sha }}-slim

    - name: Install doctl
      uses: digitalocean/action-doctl@v2
      with:
        token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}

    - name: Log in to DigitalOcean Container Registry with short-lived credentials
      run: doctl registry login --expiry-seconds 600

    - name: Push image to DigitalOcean Container Registry
      run: docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}-slim

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to DigitalOcean
        uses: appleboy/[email protected]
        env:
          REGISTRY: ${{ env.REGISTRY }}
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
          GITHUB_SHA: ${{ github.sha }}
        with:
          host: ${{ secrets.DO_HOST }}
          username: ghactions
          key: ${{ secrets.DO_SSH_PRIVATE_KEY }}
          envs: REGISTRY,IMAGE_NAME,GITHUB_SHA
          script: |
            echo ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} | docker login $REGISTRY -u ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} --password-stdin
            docker pull $REGISTRY/$IMAGE_NAME:$GITHUB_SHA
            docker stop $IMAGE_NAME || true
            docker rm $IMAGE_NAME || true
            docker run -d --restart always -e API_URL=http://host.docker.internal:8000 -p 80:8080 --name $IMAGE_NAME $REGISTRY/$IMAGE_NAME:$GITHUB_SHA

  clean-registry:
    needs: deploy
    runs-on: ubuntu-latest
    steps:
      - name: Install doctl
        uses: digitalocean/action-doctl@v2
        with:
          token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}

      - name: Log in to DigitalOcean Container Registry with short-lived credentials
        run: doctl registry login --expiry-seconds 600

      - name: Remove tag to save space
        run: doctl registry repository delete-tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} ${{ github.sha }} -f

Remember to replace your-project-name and your-app-name with your actual project and app names.

Deployment

With this setup, deployment happens automatically on each push to the main branch. You can further customize the workflow to trigger on specific conditions or branches as needed.

To access your deployed app, use your droplet's IP address:

Visit your newly deployed app at http://<ip.addr.of.droplet>

Congratulations! Your Reflex app is now deployed on DigitalOcean and set up for continuous deployment. Each time you push to the main branch, your app will automatically update.

For enhanced security and professionalism, consider setting up a domain name and configuring SSL/TLS for your application. While this is beyond the scope of this tutorial, DigitalOcean provides excellent documentation on these topics.

During my tests I encountered issues while interacting with DO docker registry, maybe because I was on the free plan.

  • "Run docker push registry.digitalocean.com/" stopped with "Error: Process completed with exit code 1." or with "denied: quota exceeded". This is either because you have been tinkering around too much (I guess data transfer), or because your image is bigger than the 500MB of the free plan docker registry capacity. If you are sure your image does not exceed the quota only thing what helped me, destroy the registry and recreate it (I did it in another region).

When I tried to reduce the size of the image, also with some multistage builds, I struggled because of nodejs dependencies alongside with python+reflex: I didn't manage it to get the size below the limit of 500MB. The only thing what helped was using the docker-slim step, which indeed brings the image down from 1.4GB to 24 MB in my case :D

So I would recommend to use paid plan when using registry and automatic deployments.

Happy coding, and may your Reflex apps thrive on DigitalOcean!


Launch your next SaaS quickly using Python 🐍

User authentication, Stripe payments, SEO-optimized blog, and many more features work out of the box on day one. Simply download the codebase and start building.