13  Deploying Data Science Projects

13.1 Understanding Deployment for Data Science

After developing your data science project, the next crucial step is deployment — making your work accessible to others. Deployment can mean different things depending on your project: publishing an analysis report (using the documentation tools from the Reporting chapter), sharing an interactive dashboard (like the Shiny and Dash applications we explored in previous chapters), or creating an API for a machine learning model.

13.1.1 Why Deployment Matters

Deployment gets little attention in data science training. It matters for four reasons:

  1. Impact: Even the most insightful analysis has no impact if it remains on your computer
  2. Collaboration: Deployment enables others to interact with your work
  3. Reproducibility: A deployed project is one whose environment and dependencies you were forced to write down
  4. Independence: You can put work in front of stakeholders without an engineering team in between

13.1.2 Static vs. Dynamic Deployment

Before selecting a deployment platform, it’s important to understand the fundamental difference between static and dynamic content:

13.1.2.1 Static Content

Static content doesn’t change based on user input and is pre-generated:

  • HTML reports from R Markdown, Jupyter notebooks, or Quarto
  • Documentation sites
  • Fixed visualisations and dashboards

Advantages:

  • Simpler to deploy
  • More secure
  • Lower hosting costs
  • Better performance

13.1.2.2 Dynamic Applications

Dynamic applications respond to user input and may perform calculations:

  • Interactive Shiny or Dash dashboards
  • Machine learning model APIs
  • Data exploration tools

Advantages:

  • Interactive user experience
  • Real-time calculations
  • Ability to handle user-specific data
  • More flexible functionality

13.1.3 Deployment Requirements by Project Type

Different data science projects have specific deployment requirements:

Project Type Interactivity Computation Data Access Suitable Platforms
Analysis reports None None None GitHub Pages, Netlify, Vercel, Quarto Pub
Interactive visualisations Medium Low Static GitHub Pages (with JavaScript), Netlify
Dashboards High Medium Often dynamic Render, Fly.io, Railway, Posit Connect Cloud
ML model APIs Low High May need database Google Cloud Run, AWS App Runner, Azure Container Apps

Understanding these requirements helps you choose the most appropriate deployment strategy.

13.2 Deployment Platforms for Data Science

Let’s examine the most relevant deployment options for data scientists, focusing on ease of use, cost, and suitability for different project types.

13.2.1 Static Site Deployment Options

13.2.1.1 GitHub Pages

GitHub Pages offers free hosting for static content directly from your GitHub repository:

Best for: HTML reports, documentation, simple visualisations Setup complexity: Low Cost: Free Limitations: Only static content, 1GB repository limit

Quick setup:

# Assuming you have a GitHub repository
# 1. Create a gh-pages branch
git checkout -b gh-pages

# 2. Add your static HTML files
git add .
git commit -m "Add website files"

# 3. Push to GitHub
git push origin gh-pages

# Your site will be available at: https://username.github.io/repository

Pushing the rendered output by hand gets old quickly. The alternative is to let GitHub render it for you on every push, using GitHub Actions: you commit your source .qmd files, and a workflow file at .github/workflows/publish.yml describes the machine that should build them.

This is continuous integration — having a server rebuild and re-test your project automatically whenever you push. This chapter uses it as a deployment mechanism and shows complete workflow files; Optimising Workflows comes back to the idea properly and explains what else it buys you. For now, read the YAML as a recipe: a description of a fresh machine, what to install on it, and what to run. We work through a complete Quarto workflow later in this chapter, under Deploying a Data Science Report to GitHub Pages.

NotePinning action versions

Workflow steps reference reusable actions by major version, as in uses: actions/checkout@v7. Those majors are republished periodically, usually because GitHub has moved the runner to a newer Node.js version, and old majors eventually stop working. The versions in this book were current when it was written. Check github.com/actions and pin to whatever the current major is when you set your workflow up.

13.2.1.2 Netlify

Netlify provides more advanced features for static sites:

Best for: Static sites that require a build process Setup complexity: Low to medium Cost: Free tier of 300 credits per month; paid plans from $9/month (Personal, 1,000 credits) Limitations: Everything consumes from one credit allowance, so a burst of traffic and a burst of rebuilds compete with each other

NoteNetlify’s credits, and why older tutorials describe something different

In September 2025 Netlify replaced its separate bandwidth and build-minute allowances with a single credit balance. Deploys, bandwidth, compute, and requests all draw from the same pool at published rates — a production deploy costs 15 credits, bandwidth 20 credits per GB — so 300 free credits is a real but small budget rather than four generous separate ones.

Tutorials written before that change describe “100GB bandwidth and 300 build minutes”. Those terms still apply to accounts created before the cutover, which is why you’ll see both stories in circulation. Any account you open now gets credits.

Quick setup:

  1. Sign up at netlify.com
  2. Connect your GitHub repository
  3. Configure build settings:
    • Build command (e.g., quarto render or jupyter nbconvert)
    • Publish directory (e.g., _site or output)

Netlify automatically rebuilds your site when you push changes to your repository.

13.2.1.3 Vercel

Vercel, from the makers of Next.js, hosts static sites and serverless functions. For our purposes the distinguishing feature over Netlify is that it will run a Python function on demand, so a mostly-static site can still call out to a small piece of server-side code without you running a server.

Best for: Static sites with interactive elements, data visualisations with JavaScript, projects using modern web frameworks Setup complexity: Low to medium Cost: Free tier for personal projects; paid plans from $20/month per team member Limitations: Optimised for frontend applications; serverless functions have execution time limits, so this is not where long-running model training belongs

Quick setup:

The simplest way to deploy to Vercel is through their web interface:

  1. Sign up at vercel.com
  2. Connect your GitHub, GitLab, or Bitbucket repository
  3. Vercel automatically detects your project type and configures build settings
  4. Click “Deploy” - your site will be live in minutes

For command-line deployment, install the Vercel CLI:

# Install Vercel CLI globally
npm install -g vercel

# From your project directory
vercel

# Follow the prompts to link your project
# Your site will be deployed and you'll get a URL

Configuration for data science projects:

Create a vercel.json file in your project root to customise the build process:

{
  "buildCommand": "quarto render",
  "outputDirectory": "_site"
}

That is the whole file. Two things you might expect to see here and shouldn’t:

  • No installCommand. Vercel’s default is npm install, which fails immediately on a Quarto or R project with no package.json. Omitting the key is not the same as accepting the default — Vercel skips the install step when there’s no lockfile to work from. If you do need Python packages, add a requirements.txt and Vercel will install from it.
  • No functions block declaring a runtime. Any .py file you place in an api/ directory is picked up as a Python serverless function automatically. Writing "runtime": "python3.12" is a common guess and Vercel rejects it outright with Function Runtimes must have a valid version — that key is for third-party community runtimes, specified as npm package versions, not for the built-in ones. The Python version is set in your project settings instead.

Example use case: if you build visualisations with Observable Plot or D3.js alongside a static Quarto report, Vercel hosts the report and a small api/ function that serves fresh data to the charts, with no server to maintain.

13.2.1.4 Quarto Pub

If you’re using Quarto for your documents, Quarto Pub offers simple publishing:

Best for: Quarto documents and websites Setup complexity: Very low Cost: Free for public content Limitations: Limited to Quarto projects

Quick setup:

# Install Quarto CLI from https://quarto.org/
# From your Quarto project directory:
quarto publish

13.2.2 Dynamic Application Deployment

NoteA note on Heroku

Heroku used to be the default recommendation for deploying small Python and R web apps, and many older tutorials still mention its free tier. Heroku discontinued its free product tiers in November 2022 and now charges for all dynos — its name for the container your application runs in. It’s still a perfectly good platform, but if you want to deploy something without entering card details, start with Render, which is the only option below with an ongoing free tier. Fly.io and Railway both offer a trial credit and then bill by usage.

13.2.2.1 Render

Render is the most direct successor to old-Heroku for data science workloads:

Best for: Python and R web applications, Dockerised dashboards Setup complexity: Medium Cost: Free tier for experimentation (services sleep after inactivity); paid plans from $7/month Limitations: Free-tier services sleep when idle and have limited compute hours

Setup for a Python web application:

  1. Create a requirements.txt file:
flask==3.1.3
pandas==3.0.5
matplotlib==3.11.1
gunicorn==26.0.0
NoteAbout the version numbers in this book

Every pinned version here was current when this was written and will be out of date by the time you read it — that’s the nature of printing version numbers. Pinning is still the right practice; the specific numbers are illustrative.

The reliable way to produce this file is to generate it from an environment you’ve actually tested, rather than copying versions out of a book:

uv pip freeze > requirements.txt

See Reproducible Environments. Check PyPI, or run pip index versions <package>, when you want to know what’s current.

  1. Push your project to a GitHub repository
  2. Sign up at render.com
  3. Connect your GitHub repository
  4. Create a new Web Service and configure it:
    • Environment: Python
    • Build Command: pip install -r requirements.txt
    • Start Command: gunicorn app:app
  5. Add any required environment variables (API keys, database URLs) in the Environment tab. Never commit secrets to your repo.

Render builds and deploys your app on every push to the configured branch, so there’s no separate git push render main step.

NoteTwo pieces of jargon in that start command

gunicorn app:app will appear in every deployment example from here on, so it’s worth unpacking.

Gunicorn is a production web server for Python. Flask, Dash, and Streamlit all ship with their own built-in server, and every one of them prints a warning telling you not to use it in production — it handles one request at a time and has no protection against malformed input from the open internet. Gunicorn runs your app across several worker processes and is built to face real traffic. You don’t change your code to use it; you just start the app with gunicorn instead of python app.py.

app:app is module:variable. The part before the colon is the Python file to import (app.py, without the .py); the part after is the name of the object inside that file which gunicorn should serve. For a Flask app created with app = Flask(__name__) in app.py, both halves happen to be app — which is why the notation looks redundant until you meet a Dash app, where the servable object is server, and the command becomes gunicorn app:server.

13.2.2.2 Fly.io and Railway

Two other platforms worth knowing for small to mid-sized Python/R deployments:

  • Fly.io: Deploys Docker containers globally on a pay-as-you-go basis. Good fit once you’re comfortable with Docker (covered in the previous chapter); the flyctl command-line tool handles the build, push, and deploy steps with one command.
  • Railway: Similar developer experience to Render, with a one-time trial credit to try it out before committing. Good Postgres and cron support out of the box.

All three platforms use essentially the same mental model: connect a Git repo, describe how to build the app (either requirements.txt + a start command or a Dockerfile), and let the platform handle HTTPS, logging, and redeploys on push.

13.2.2.3 Posit Connect Cloud (and the shinyapps.io migration)

For R Shiny, Quarto, Streamlit, and Dash content, Posit’s hosted platform is the path of least resistance.

Importantshinyapps.io is being retired — start on Connect Cloud

If you have read about deploying Shiny apps anywhere else, you have read about shinyapps.io. It still works, but Posit is consolidating it into Posit Connect Cloud, and has said that all shinyapps.io users will be migrated by the end of 2026. Existing URLs get permanent redirects, so links already shared keep working. Check shinyapps.io for the current schedule before you plan around a specific date.

The practical advice depends on where you are:

  • Starting fresh? Go straight to Connect Cloud. Don’t learn a workflow that’s being retired.
  • Already on shinyapps.io? Nothing breaks, but migrate at a time you choose rather than waiting for the automatic move.

Everything below targets Connect Cloud. The rsconnect package still handles both, and the deploy call is nearly identical, so the skill transfers either way.

Best for: R Shiny apps, Quarto documents, Streamlit and Dash applications Setup complexity: Low Cost: Free tier available; interactive applications count against plan limits differently from static documents, so check current terms if you plan to host several Limitations: Tied to Posit’s platform — for full control over the runtime, use a container on Render or Cloud Run instead

Deployment from Git (the recommended path):

Connect Cloud publishes directly from a GitHub repository. Push your app, link the repo in the Connect Cloud interface, and it redeploys on every push — the same model as Render and Netlify. There are no tokens to manage in code, which makes it both the easiest and the safest route.

Deployment from the IDE:

The Publish button in RStudio (the blue arrow next to Run in the editor for app.R) walks you through linking an account and deploys in one click.

Deployment from the console:

If you script your deploys, install rsconnect and keep credentials out of the source file. Read them from environment variables set in your OS or in a .Renviron that’s listed in .gitignore:

install.packages("rsconnect")

# One-time setup. Opens a browser and links your Connect Cloud account.
rsconnect::connectCloudUser()

# Deploy your app
rsconnect::deployApp(
  appDir  = "path/to/your/app",
  appName = "my-shiny-app",
  account = "your-account-name"
)

For a script that runs unattended — in CI, say — there is no browser to open. Create a service account in Connect Cloud, then authenticate with its OAuth client credentials, read from the environment rather than written into the file:

rsconnect::connectCloudClientCredentials(
  clientId     = Sys.getenv("CONNECT_CLOUD_CLIENT_ID"),
  clientSecret = Sys.getenv("CONNECT_CLOUD_CLIENT_SECRET"),
  accountName  = "your-account-name"
)
WarningsetAccountInfo() is the shinyapps.io call, not the Connect Cloud one

Almost every tutorial you will find configures rsconnect with setAccountInfo(name, token, secret). That is the shinyapps.io function, and it was the only one that mattered for a decade. Connect Cloud uses the connectCloud* functions above; connectApiUser() is a third thing again, for self-hosted Posit Connect servers. Three products, three registration calls, and the error you get from picking the wrong one does not tell you which one you wanted.

TipPublishing Quarto to Connect Cloud

Quarto has native support: quarto publish from the project directory offers Connect Cloud as a target and handles the rest. Useful for reports and dashboards that don’t need a Shiny server.

NoteNo server at all: Shinylive

For apps whose computation is light enough to run in the browser, Shinylive compiles an R or Python Shiny app to WebAssembly, a format modern browsers can execute directly. The result is static files you can host anywhere — GitHub Pages, Netlify, an S3 bucket — with no server, no hosting bill, no active-hours limit, and no delay on the first visit while a sleeping service wakes up.

The trade-off is real: everything runs on the user’s machine, so the data ships to the browser and heavy computation will be slow. But for a teaching demo, a small interactive figure, or a calculator over a modest dataset, it removes the entire deployment problem.

13.2.3 Cloud Platform Deployment

For more complex or production-level deployments, cloud platforms offer greater flexibility and scalability:

13.2.3.1 Google Cloud Run

Cloud Run is ideal for containerised applications:

Best for: Containerised applications that need to scale Setup complexity: Medium to high Cost: Pay-per-use, with a monthly free allowance of requests and compute time Limitations: Requires Docker knowledge

Before you can deploy: Cloud Run is driven by the Google Cloud CLI, which is a separate install. This is a one-off, and every gcloud command in this chapter assumes it:

  1. Install the Google Cloud CLI for your platform — there’s a Windows installer, and a brew install --cask google-cloud-sdk for macOS

  2. Authenticate. This opens a browser and links the CLI to your Google account:

    gcloud auth login
  3. Create or select a project, and set it as the default so you don’t have to name it every time. Projects are how Google Cloud groups resources and bills them:

    gcloud projects create my-analysis-project    # or skip, if you have one
    gcloud config set project my-analysis-project
  4. Enable the two services this uses, and link a billing account to the project. Cloud Run has a monthly free allowance, but Google still requires a billing account on file before it will run anything:

    gcloud services enable run.googleapis.com cloudbuild.googleapis.com

Confirm the CLI is working and pointed at the right project with gcloud config list.

The AWS equivalent is aws configure after installing the AWS CLI; Azure’s is az login. All three follow the same shape — install a command-line tool, authenticate it against your account in a browser, then set a default project or region.

Deployment steps:

From a directory containing a Dockerfile, one command does everything — Google builds the image for you, stores it, and deploys it:

gcloud run deploy app-name \
  --source . \
  --region us-central1 \
  --allow-unauthenticated
WarningOlder tutorials will tell you to push to gcr.io

You will find a great many Cloud Run guides that begin docker build -t gcr.io/your-project/app-name . followed by docker push. Don’t follow them. That is Google Container Registry, which was retired in 2025 — it stopped accepting pushes in March of that year and stopped serving reads in June. Its replacement is Artifact Registry, whose image paths look like us-central1-docker.pkg.dev/your-project/your-repo/app-name.

The --source . flag above sidesteps the question entirely: Cloud Build produces the image and files it in Artifact Registry without you naming a registry at all. Push images yourself only when you need the image to exist before the deploy — for example when the same image is deployed to several services.

13.2.3.2 AWS App Runner

App Runner takes a container image or a source repository and runs it as a scaling web service, which puts it in roughly the same category as Cloud Run. Choose it when your organisation is already on AWS and keeping everything in one account matters; otherwise Cloud Run is the easier path and the one the worked example later in this chapter uses.

Best for: Production web applications already committed to AWS Setup complexity: Medium Cost: Pay for provisioned and active compute Limitations: Fewer regions than the rest of AWS; no free tier

Deployment is normally driven from the console or from an apprunner.yaml in your repository. If you are not already in the AWS ecosystem, Cloud Run and Render both reach a running container with less ceremony.

13.3 Step-by-Step Deployment Guides

Let’s walk through complete deployment workflows for common data science scenarios.

NoteA note on the example data and model files

The examples below reference placeholder files like sales_data.csv and model.pkl, which stand in for your own data or trained model rather than being shipped with the book. (data/my_data.csv is the exception — that one is in the book’s repository, and the reporting examples use it.) When you follow along, either point the code at one of your own files, substitute a built-in dataset (e.g. ggplot2::diamonds for R, sns.load_dataset("tips") for Python), or pull a stable public CSV from a URL as shown in the Reporting and Visualisation chapters. The deployment mechanics don’t depend on which file you use.

13.3.1 Deploying a Data Science Report to GitHub Pages

This example shows how to publish an analysis report created with Quarto:

  1. Create your Quarto document:
---
title: "Sales Analysis Report"
author: "Your Name"
format: html
---

## Executive Summary

Our analysis shows a 15% increase in Q4 sales compared to the previous year.

```{r}
#| echo: false
#| warning: false
library(ggplot2)
library(dplyr)
library(here)

# Load data
sales <- read.csv(here("data", "my_data.csv"))

# Create visualisation
ggplot(sales, aes(x = Product, y = Sales, fill = Product)) +
  geom_bar(stat = "identity", position = "dodge") +
  theme_minimal() +
  labs(title = "Product Comparison")
```
  1. Set up a GitHub repository for your project

  2. Create a GitHub Actions workflow file at .github/workflows/publish.yml:

name: Publish Quarto Site

on:
  push:
    branches: [main]

jobs:
  build-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Check out repository
        uses: actions/checkout@v7

      - name: Set up Quarto
        uses: quarto-dev/quarto-actions/setup@v2

      - name: Install R
        uses: r-lib/actions/setup-r@v2
        with:
          r-version: 'release'

      - name: Install R Dependencies
        uses: r-lib/actions/setup-r-dependencies@v2
        with:
          # The `|` matters. Without it YAML folds these four lines into
          # a single space-joined string and the action fails to parse them.
          packages: |
            any::knitr
            any::rmarkdown
            any::ggplot2
            any::dplyr
            any::here

      - name: Render and Publish
        uses: quarto-dev/quarto-actions/publish@v2
        with:
          target: gh-pages
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  1. Push your changes to GitHub:
git add .
git commit -m "Add analysis report and GitHub Actions workflow"
git push origin main
  1. Enable GitHub Pages in your repository settings, selecting the gh-pages branch as the source

Your report will be automatically published each time you push changes to your repository, making it easy to share with stakeholders.

13.3.2 Deploying a Dash Dashboard to Render

This example demonstrates deploying an interactive Python dashboard:

  1. Create your Dash application (app.py):
import dash
from dash import dcc, html
from dash.dependencies import Input, Output
import pandas as pd
import plotly.express as px

# Load data
df = pd.read_csv('sales_data.csv')

# Initialize app
app = dash.Dash(__name__, title="Sales Dashboard")
server = app.server  # For Render deployment

# Create layout
app.layout = html.Div([
    html.H1("Sales Performance Dashboard"),
    
    html.Div([
        html.Label("Select Year:"),
        dcc.Dropdown(
            id='year-filter',
            options=[{'label': str(year), 'value': year} 
                     for year in sorted(df['year'].unique())],
            value=df['year'].max(),
            clearable=False
        )
    ], style={'width': '30%', 'margin': '20px'}),
    
    dcc.Graph(id='sales-graph')
])

# Create callback
@app.callback(
    Output('sales-graph', 'figure'),
    Input('year-filter', 'value')
)
def update_graph(selected_year):
    filtered_df = df[df['year'] == selected_year]
    
    fig = px.bar(
        filtered_df, 
        x='quarter', 
        y='sales',
        color='product',
        barmode='group',
        title=f'Quarterly Sales by Product ({selected_year})'
    )
    
    return fig

if __name__ == '__main__':
    # debug=True must NEVER be used in production. It exposes
    # an interactive Python console to anyone who can reach the app.
    # Render runs `gunicorn` directly in production, so this block
    # is only used when you run `python app.py` locally.
    app.run(debug=False)
  1. Create a requirements.txt file:
dash==4.4.1
pandas==3.0.5
plotly==6.9.0
gunicorn==26.0.0
  1. Create a minimal Dockerfile:
FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD gunicorn app:server -b 0.0.0.0:$PORT
NoteWhere $PORT comes from

Render, Cloud Run, Fly.io and Heroku all set a PORT environment variable inside your container and expect the app to listen on it. You don’t choose the number and you shouldn’t hardcode one — the platform picks it and routes traffic there.

The catch is local testing. Run this image on your own machine and $PORT is empty, so gunicorn tries to bind to 0.0.0.0: and fails with an unhelpful error. Supply one yourself:

docker run -e PORT=8080 -p 8080:8080 my-dashboard
  1. Sign up for Render and connect your GitHub repository

  2. Create a new Web Service on Render with these settings:

    • Name: your-dashboard-name
    • Environment: Docker
    • Build Command: (leave empty when using Dockerfile)
    • Start Command: (leave empty when using Dockerfile)
  3. Deploy your application

Your interactive dashboard will be available at the URL provided by Render.

13.3.3 Deploying a Shiny Application to Posit Connect Cloud

This example shows how to deploy an R Shiny dashboard:

  1. Create a Shiny app directory with app.R:
library(shiny)
library(ggplot2)
library(dplyr)
library(here)
library(DT)      # install.packages("DT") - interactive tables

# Load data
sales <- read.csv(here("data", "my_data.csv"))

# UI
ui <- fluidPage(
  titlePanel("Sales Analysis Dashboard"),
  
  sidebarLayout(
    sidebarPanel(
      selectInput("Date", "Select Date:",
                  choices = unique(sales$Date),
                  selected = max(sales$Date)),
      
      checkboxGroupInput("Products", "Select Products:",
                         choices = unique(sales$Product),
                         selected = unique(sales$Product)[1])
    ),
    
    mainPanel(
      plotOutput("salesPlot"),
      DT::DTOutput("salesTable")
    )
  )
)

# Server
server <- function(input, output) {
  
  filtered_data <- reactive({
    sales |>
      filter(Date == input$Date,
             Product %in% input$Products)
  })
  
  output$salesPlot <- renderPlot({
    ggplot(filtered_data(), aes(x = Date, y = Sales, fill = Product)) +
      geom_bar(stat = "identity", position = "dodge") +
      theme_minimal() +
      labs(title = paste("Sales for", input$Date))
  })
  
  output$salesTable <- DT::renderDT({
    filtered_data() |>
      group_by(Product) |>
      summarise(Total = sum(Sales),
                Average = mean(Sales))
  })
}

# Run the application
shinyApp(ui = ui, server = server)

Note DT::DTOutput() and DT::renderDT() rather than Shiny’s own dataTableOutput() and renderDataTable(). Shiny deprecated those years ago in favour of the DT package, and calling them now prints deprecation messages to your console that are hard to interpret if you don’t know what they refer to. Most tutorials still show the old names.

  1. Push the app directory to a GitHub repository and link it in Connect Cloud, which redeploys on every push. If you’d rather deploy from the console, configure rsconnect as shown in the Connect Cloud section above, reading your credentials from the environment rather than writing them into the script

  2. Deploy your application:

rsconnect::deployApp(
  appDir = "path/to/your/app",  # Directory containing app.R
  appName = "sales-dashboard",  # Name for your deployed app
  account = "your-account-name" # Your Posit account name
)
  1. Share the provided URL with your stakeholders

The deployed app gets a public URL you can share. Apps already published to shinyapps.io keep their existing https://your-account-name.shinyapps.io/... address, which redirects to the Connect Cloud URL after migration.

13.3.4 Deploying a Machine Learning Model API

This example demonstrates deploying a machine learning model as an API:

  1. Create a Flask API for your model (app.py):
import os
import pickle

from flask import Flask, request, jsonify
import pandas as pd

# Initialize Flask app
app = Flask(__name__)

# Load the pre-trained model
with open('model.pkl', 'rb') as file:
    model = pickle.load(file)

@app.route('/predict', methods=['POST'])
def predict():
    try:
        # Get JSON data from request
        data = request.get_json(force=True)
        if not isinstance(data, dict):
            return jsonify({
                'status': 'error',
                'message': 'Request body must be a JSON object of feature: value pairs',
            }), 400

        # Convert to DataFrame
        input_data = pd.DataFrame(data, index=[0])

        # Make prediction
        prediction = model.predict(input_data)[0]

        # Return prediction as JSON
        return jsonify({
            'status': 'success',
            'prediction': float(prediction),
            'input_data': data,
        })

    except Exception as e:
        return jsonify({
            'status': 'error',
            'message': str(e),
        }), 400

@app.route('/health', methods=['GET'])
def health():
    return jsonify({'status': 'healthy'})

if __name__ == '__main__':
    # In production, gunicorn runs the app directly. This block is
    # only used for local development.
    app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))

For production use you would want stricter input validation (for example with Pydantic or Marshmallow) and authentication. The try/except above only catches shape errors, not adversarial input.

  1. Create a requirements.txt file:
flask==3.1.3
pandas==3.0.5
scikit-learn==1.9.0
gunicorn==26.0.0
  1. Create a Dockerfile:
FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD gunicorn --bind 0.0.0.0:$PORT app:app
  1. Deploy to Google Cloud Run:
# Build the container from the Dockerfile in this directory and deploy it.
# Cloud Build produces the image; Artifact Registry stores it. You don't
# have to name a registry.
gcloud run deploy model-api \
  --source . \
  --region us-central1 \
  --allow-unauthenticated
Warning--allow-unauthenticated exposes the endpoint to the public internet

For a quick demo this is fine, but don’t leave a model API wide open to the internet without rate limiting and authentication. For production use, drop --allow-unauthenticated and require callers to present an identity token (gcloud auth print-identity-token), or put an API gateway — a service that sits in front of yours and handles authentication, rate limiting, and routing before requests reach it — in front of the service. At minimum, monitor the Cloud Run request count and set budget alerts so you notice unexpected traffic early.

  1. Test your API. Replace the host below with the URL gcloud run deploy printed when it finished — it is unique to your service and the xxxx-xx here is a stand-in:
# Bash, Git Bash, WSL, macOS
curl -X POST \
  https://model-api-xxxx-xx.a.run.app/predict \
  -H "Content-Type: application/json" \
  -d '{"feature1": 0.5, "feature2": 0.8, "feature3": 1.2}'

In Windows PowerShell, curl is an alias for Invoke-WebRequest, which doesn’t understand -X or -d, and line continuation uses a backtick rather than a backslash. Either call the real curl.exe explicitly:

curl.exe -X POST `
  https://model-api-xxxx-xx.a.run.app/predict `
  -H "Content-Type: application/json" `
  -d '{"feature1": 0.5, "feature2": 0.8, "feature3": 1.2}'

or use the native cmdlet:

Invoke-RestMethod -Method Post `
  -Uri https://model-api-xxxx-xx.a.run.app/predict `
  -ContentType "application/json" `
  -Body '{"feature1": 0.5, "feature2": 0.8, "feature3": 1.2}'

This API allows other applications to access your machine learning model’s predictions.

13.4 Deployment Best Practices

Regardless of the platform you choose, these best practices will help ensure successful deployments:

13.4.1 Environment Management

  1. Use environment files: Include requirements.txt for Python or renv.lock for R
  2. Specify exact versions: Use pandas==3.0.5 rather than pandas>=3.0
  3. Minimise dependencies: Include only what you need to reduce deployment size
  4. Test in a clean environment: Verify your environment files are complete

13.4.2 Security Considerations

  1. Never commit secrets: Keep API keys, database passwords, and access tokens in environment variables, platform-managed secret stores (Render Environment, Fly.io secrets, GCP Secret Manager, AWS Secrets Manager), or a local .env file that is listed in .gitignore. Data Stores covers the mechanics, including what to do if one leaks.
  2. Set up proper authentication: Restrict access to sensitive applications. Don’t leave --allow-unauthenticated endpoints, default passwords, or debug=True enabled in anything publicly reachable.
  3. Implement input validation: Protect against malicious inputs, especially for any endpoint that touches a database or a machine learning model.
  4. Use HTTPS: Every modern platform-as-a-service (Render, Fly, Railway, Vercel, Cloud Run) issues the certificates that make HTTPS work automatically and at no cost; use them. Custom domains should always be served over HTTPS.
  5. Regularly update dependencies: Address security vulnerabilities. Tools like GitHub Dependabot or pip-audit cross-check your requirements.txt against public databases of known flaws and tell you which of your packages need upgrading.

13.4.3 Observability: Logging, Health Checks, and Monitoring

Once your application is deployed, you need to know when it breaks:

  1. Emit structured logs to stdout/stderr: Modern platforms capture anything your app prints and surface it in their dashboards. Use Python’s logging module (or lgr / futile.logger in R) rather than print, and log in JSON where possible so the logs are searchable.

  2. Expose a /health endpoint: A simple route that returns HTTP 200 when the app is alive lets the platform (and uptime monitors like UptimeRobot or BetterStack) restart unhealthy instances automatically. The ML API example in this chapter already does this.

  3. Add a Docker HEALTHCHECK in your Dockerfile so the container itself reports its status:

    HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
      CMD python -c "import os,urllib.request; \
        urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\",8080)}/health')"

    Most healthcheck examples you’ll find online use curl instead. That works on a Debian or Ubuntu base image but fails silently-ish on the slim Python images used throughout this book — python:3.13-slim doesn’t ship curl, so the check errors on every run and Docker marks a perfectly healthy container as unhealthy. Calling Python, which is guaranteed to be there, avoids the problem. Use curl only if you’ve explicitly installed it in the image.

  4. Track errors separately: Services like Sentry or Better Stack give you grouped stack traces and notifications when something goes wrong. Both have free tiers sized for small projects.

  5. Set budget alerts: On pay-per-use platforms (Cloud Run, AWS, GCP) configure a monthly budget with email alerts before you deploy anything that autoscales.

13.4.4 Performance Optimisation

  1. Optimise data loading: Load data efficiently or use databases for large datasets
  2. Implement caching: Cache results of expensive computations
  3. Monitor resource usage: Keep track of memory and CPU utilisation
  4. Implement pagination: For large datasets, display data in manageable chunks
  5. Consider asynchronous processing: Use background tasks for long-running computations

13.4.5 Documentation

  1. Create a README: Document deployment steps and dependencies
  2. Add usage examples: Show how to interact with your deployed application
  3. Include contact information: Let users know who to contact for support
  4. Provide version information: Display the current version of your application
  5. Document API endpoints: If applicable, describe available API endpoints

13.5 Troubleshooting Common Deployment Issues

13.5.1 Platform-Specific Issues

13.5.1.1 GitHub Pages

Issue Solution
Changes not showing up Check if you’re pushing to the correct branch
Build failures Review the GitHub Actions logs for errors
Custom domain not working Verify DNS settings and CNAME file

13.5.1.2 Render / Fly.io

Issue Solution
Application crash Check the service’s Logs tab in the dashboard (flyctl logs on Fly.io)
Build failures Ensure dependencies are pinned in requirements.txt and that the build command is correct
Free service sleeping when idle Use periodic health-check pings, or upgrade to a paid tier that stays warm

13.5.1.3 Posit Connect Cloud

Issue Solution
Package installation failures Use renv to capture exact package versions in a lockfile
Application timeout Optimise data loading and computation
Deployment failures Check rsconnect logs in RStudio

13.5.2 General Deployment Issues

  1. Missing dependencies:
    • Review error logs to identify missing packages
    • Ensure all dependencies are listed in your environment files
    • Test your application in a clean environment
  2. Environment variable problems:
    • Verify environment variables are set correctly
    • Check for typos in variable names
    • Use platform-specific ways to set environment variables
  3. File path issues:
    • Use relative paths instead of absolute paths
    • Be mindful of case sensitivity on Linux servers
    • Use appropriate path separators for the deployment platform
  4. Permission problems:
    • Ensure application has necessary permissions to read/write files
    • Check file and directory permissions
    • Use platform-specific storage solutions for persistent data
  5. Memory limitations:
    • Optimise data loading to reduce memory usage
    • Use streaming approaches for large datasets
    • Upgrade to a plan with more resources if necessary

13.6 Conclusion

Deployment is ongoing. As projects evolve you will redeploy, read logs, and fix what breaks — which is why the platform choices in this chapter were weighted towards the ones that make redeploying cheap.

By the end of this chapter you should have:

  • A Quarto report that rebuilds and republishes to GitHub Pages on every push
  • At least one dynamic application — Shiny, Dash, or Flask — running on a public URL
  • Every credential in a platform secret store or a .gitignored .Renviron / .env, and none in a file you’ve committed
  • A requirements.txt or renv.lock complete enough that the deployment built from a clean environment

If the second one is still failing, the cause is nearly always one of three things: a missing dependency in the environment file, an app bound to 127.0.0.1 instead of 0.0.0.0, or a hardcoded port where the platform expected $PORT.

The next chapter turns from getting one project deployed to making the whole cycle repeatable: project structure, automation, testing, and the continuous integration that catches a broken environment file before your users do.