How To Containerize A Twilio App With Docker

March 09, 2026
Written by

How To Containerize A Twilio App With Docker

Say you've built a project that works perfectly on your machine, but deploying it means wrestling with dependency conflicts, secret management, and environment differences. Docker solves this by packaging your app and its dependencies into a single image that runs the same way everywhere. In this tutorial, you'll build a simple SMS keyword responder using Twilio Programmable Messaging and FastAPI, then containerize it with Docker so it's ready to deploy to any platform.

Prerequisites

To follow along, you'll need:

Building the application

Setting up the project

Start by creating a directory for the project, navigating into it, and creating a requirements.txt file:

mkdir twilio-sms-docker
cd twilio-sms-docker
touch requirements.txt

Populate requirements.txt with the following contents:

fastapi==0.115.0
uvicorn[standard]==0.34.0
twilio==9.4.0
python-multipart==0.0.20

Create the app/ directory and a main.py file inside the root directory. Keeping application code in a subdirectory lets the Dockerfile copy only that folder into the image, leaving config files like .env on your host machine.

mkdir app
touch app/main.py

Writing the FastAPI webhook

Open app/main.py in your editor. Start with the imports and keyword map:

import os
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import Response
from twilio.request_validator import RequestValidator
from twilio.twiml.messaging_response import MessagingResponse

app = FastAPI()

KEYWORDS = {
    "HOURS": "We're open Monday-Friday, 9am to 5pm PT.",
    "OPTIONS": "Reply HOURS for business hours, or visit twilio.com for support.",
}

KEYWORDS is a plain dictionary that maps incoming SMS keywords to reply strings. You can extend it with as many keywords as you need.

Next, add the signature validation:

def get_validator() -> RequestValidator:
    return RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])

async def require_twilio_signature(
    request: Request,
    validator: RequestValidator = Depends(get_validator),
) -> dict:
    form_data = await request.form()
    signature = request.headers.get("X-Twilio-Signature", "")
    url = str(request.url)
    if not validator.validate(url, dict(form_data), signature):
        raise HTTPException(status_code=403, detail="Forbidden")
    return dict(form_data)

get_validator creates a RequestValidator instance using your TWILIO_AUTH_TOKEN from the environment. require_twilio_signature is a FastAPI dependency that runs before your endpoint handler. It reads the raw form data that Twilio sends, grabs the X-Twilio-Signature header, and validates the request using the RequestValidator. If the signature doesn't match, the dependency raises a 403 error and the handler never runs.

Finally, add the endpoint:

@app.post("/sms")
async def sms_reply(form_data: dict = Depends(require_twilio_signature)):
    body = form_data.get("Body", "").strip().upper()
    reply = KEYWORDS.get(body, "Sorry, I didn't recognize that keyword. Reply OPTIONS for options.")
    resp = MessagingResponse()
    resp.message(reply)
    return Response(content=str(resp), media_type="text/xml")

The endpoint reads the Body parameter Twilio sends with every inbound SMS, uppercases it for case-insensitive matching, and looks it up in KEYWORDS. It builds a MessagingResponse with the appropriate reply and returns it as TwiML (Twilio Markup Language) XML. Twilio reads that XML response and sends the reply message on your behalf.

Writing the Dockerfile

Create a file named Dockerfile in the project root:

# syntax=docker/dockerfile:1
FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

COPY app/ ./app/

RUN adduser --disabled-password --no-create-home appuser
USER appuser

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=172.0.0.0/8"]

The # syntax=docker/dockerfile:1 directive at the top opts into Docker BuildKit's extended syntax, which the RUN --mount=type=cache instruction requires. Modern Docker Desktop enables BuildKit by default, but the directive makes it explicit so builds work the same everywhere.

FROM python:3.13-slim uses a minimal Debian-based image that strips out documentation and other files you don't need at runtime, keeping the image small.

Copying requirements.txt before copying your application code is a layer-caching optimization. Docker rebuilds layers from the point of the first change. If you copy all your files first and then install dependencies, every code change triggers a full reinstall. By copying requirements.txt first and installing, Docker can reuse the cached dependency layer on subsequent builds as long as requirements.txt hasn't changed.

The adduser command creates an unprivileged system account, and USER appuser switches to it before starting the server. Running application code as root inside a container is a security risk. If an attacker exploits a vulnerability in your app, they shouldn't get root access to the container's filesystem.

The --proxy-headers flag on uvicorn tells it to trust X-Forwarded-For and X-Forwarded-Proto headers from upstream proxies. You'll need this when ngrok (or a load balancer in production) sits in front of the container, because Twilio signs the request using the original HTTPS URL and uvicorn needs to reconstruct that URL correctly for signature validation to pass. The --forwarded-allow-ips=172.0.0.0/8 flag tells uvicorn to only trust proxy headers from Docker's internal network, so requests from outside can't spoof forwarded headers. The 172.0.0.0/8 range covers the entire Docker private address space, ensuring any network Docker assigns will be trusted as a proxy source.

Configuring Docker Compose

Create docker-compose.yml:

services:
  web:
    build: .
    ports:
      - "8000:8000"
    env_file:
      - .env

  ngrok:
    image: ngrok/ngrok:latest
    command: http web:8000
    ports:
      - "4040:4040"
    environment:
      - NGROK_AUTHTOKEN=${NGROK_AUTHTOKEN}
    depends_on:
      - web

The web service builds your application image and maps port 8000. The env_file directive tells Compose to read a .env file and inject its contents as environment variables into the container at runtime. Compose never copies the .env file into the image itself. It reads the file on your host machine and passes the values in when the container starts. Your Docker image contains no secrets.

The ngrok service runs alongside your app and creates a secure tunnel from a public HTTPS URL to the web container. Twilio needs this public URL to reach your webhook. The depends_on directive ensures the app starts before ngrok tries to connect to it. Port 4040 exposes ngrok's local web inspector, which you can use to view the assigned public URL and inspect request traffic.

Create a .env file:

touch .env

Fill in your real credentials:

TWILIO_AUTH_TOKEN=your_auth_token_here
NGROK_AUTHTOKEN=your_ngrok_auth_token_here

Find your Auth Token in the Twilio Console by clicking the eye icon next to the masked value on the main dashboard. Find your ngrok auth token at dashboard.ngrok.com.

Finally, create .gitignore to make sure your secrets never end up in source control:

.env
__pycache__/
*.pyc

It's also good practice to create a .dockerignore file so the .env file doesn't accidentally get included in the Docker build context:

.env
.git
__pycache__
*.pyc
.venv/

Building and running the container

With all the files in place, build the image and start the container:

docker compose up --build

The --build flag tells Compose to rebuild the image before starting. You'll see Docker pull the base image, install dependencies, and copy your application code. Once it's running, you should see uvicorn's startup output:

web-1  | INFO:     Started server process [1]
web-1  | INFO:     Waiting for application startup.
web-1  | INFO:     Application startup complete.
web-1  | INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

Your app is now running on port 8000 inside Docker.

Getting your public URL

Because ngrok is defined in docker-compose.yml, it starts automatically alongside your app. To grab the public HTTPS URL ngrok assigned, run:

curl -s http://localhost:4040/api/tunnels | grep -o 'https://[^"]*' | head -1

This queries ngrok's local API and extracts the HTTPS forwarding URL. You can also visit http://localhost:4040 in your browser to see ngrok's web inspector, which shows the URL and lets you replay and inspect request traffic.

Configuring your Twilio webhook

Copy the URL from the previous step and append /sms. Log in to the Twilio Console and navigate to Phone Numbers > Manage > Active Numbers. Click your phone number, scroll to the Messaging section, and set A message comes in to:

https://your-ngrok-url.ngrok-free.app/sms

Make sure the HTTP method is set to HTTP POST. Click Save configuration.

Testing the application

On your phone, send an SMS to your Twilio number. Try texting HOURS and then OPTIONS. You should receive the corresponding replies within a few seconds.

To watch what's happening in real time, check the container logs in your first terminal (where docker compose up is running). You'll see a line like this for each incoming request:

web-1  | INFO:     172.18.0.1:54312 - "POST /sms HTTP/1.1" 200 OK

If you're seeing 403 responses, the most common cause is a URL mismatch. Twilio signs the request using the exact URL you registered in the console. Make sure the URL in the Twilio console exactly matches what ngrok is exposing, including the /sms path and the https:// scheme. Check that uvicorn's --proxy-headers flag is present in the Dockerfile CMD line and that you rebuilt the image after any Dockerfile changes.

If you see requests hitting your server but form fields are empty (Body is blank), confirm that python-multipart is in requirements.txt and that the image was rebuilt with docker compose up --build.

Conclusion

You've built an SMS keyword responder with FastAPI, containerized it with Docker, and ran it locally against your live Twilio phone number. The same Docker image you built today can be deployed to any container platform without changing a line of code. As a next step, try adding a HEALTHCHECK instruction to your Dockerfile so your orchestrator can verify the app is running, or explore listing the image in a registry like Docker Hub and pointing your Twilio webhook at the production URL.

Dylan Frankcom is a Software Engineer on Twilio's Developer Content team. He's passionate about building tools that help developers learn, create, and ship faster. You can reach him at dfrankcom [at] twilio.com, find him on LinkedIn , or follow him on Github .