The previous tutorial demonstrated deploying to a traditional virtual private server, requiring management of everything from the operating system and security updates to Nginx and systemd configuration. While that approach teaches fundamentals and provides total control, it also carries significant responsibility. Watching that tutorial is not required to follow this one, though it will be referenced occasionally for comparison.

This tutorial takes a different approach: packaging the application into a Docker container and delegating infrastructure management to Google Cloud Run. This provides automatic scaling, managed SSL, and the ability to scale to zero when the application is unused—paying only for actual usage. The deployment will also use Neon, a cloud-hosted PostgreSQL database, eliminating the need to manage database servers.

Adding Security Headers Middleware

Tutorial 18 included one code change beyond infrastructure: adding a health check endpoint. If jumping directly from Tutorial 17, add this endpoint to main.py:

from sqlalchemy import text
 
@app.get("/health", include_in_schema=False)  
async def health_check(db: Annotated[AsyncSession, Depends(get_db)]):  
    try:  
        await db.execute(text("SELECT 1"))  
        return {"status": "healthy"}  
    except Exception as exc:  
        raise HTTPException(  
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,  
            detail="Database connection failed"  
        ) from exc

This performs a simple SELECT 1 on the database to verify connection health. Health checks are essential—load balancers and monitoring systems use them to determine if applications run correctly.

Security Headers Without Nginx

Tutorial 18’s Nginx configuration added security headers to all responses. Cloud Run doesn’t place Nginx in front of the app, so the application must add headers itself. This is arguably superior—security headers living in application code work regardless of deployment location.

Add this middleware after router includes in main.py:

@app.middleware("http")  
async def add_security_headers(request: Request, call_next):  
    response = await call_next(request)  
  
    response.headers["X-Frame-Options"] = "SAMEORIGIN"  
    response.headers["X-Content-Type-Options"] = "nosniff"  
  
    # Only set if not already set (e.g., by reset password page)  
    if "Referrer-Policy" not in response.headers:  
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"  
  
    # Skip HSTS for localhost to allow plain HTTP in development  
    if request.url.hostname not in ("localhost", "127.0.0.1"):  
        response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains"  
  
    return response

This is the first middleware used in the application. The decorator @app.middleware("http") designates the function add_security_headers, which takes request and call_next as parameters. call_next is a function passing the request to whatever route should handle it, returning the response. Middleware sits between—it can act before request handling, after, or both. This middleware lets the request proceed normally with await call_next(request), then adds headers to the response before returning it to the client.

Header explanations:

  • X-Frame-Options: SAMEORIGIN prevents other websites from embedding the site in iframes, protecting against clickjacking attacks
  • X-Content-Type-Options: nosniff tells browsers to trust the Content-Type header sent rather than guessing content types
  • Referrer-Policy: strict-origin-when-cross-origin is set only if not already present—the reset password page sets Referrer-Policy: no-referrer to protect reset tokens in URLs, which shouldn’t be overwritten
  • Strict-Transport-Security tells browsers to always use HTTPS when visiting the site, but is skipped for localhost and 127.0.0.1 so local development works over plain HTTP

Every response from the application now includes these security headers.

Don’t be intimidated by unfamiliar security patterns. These headers come from online recommendations for website security hardening, not from memory. Nginx added these headers in the previous tutorial; they’re replicated here to keep deployments similar.

Test the middleware by starting the development server and visiting the site. Open browser dev tools, navigate to the Network tab, reload the page, and inspect request headers. The security headers should appear (except Strict-Transport-Security on localhost, as designed since HTTPS isn’t available locally).

Installing Docker

Docker Desktop installation varies by operating system. On macOS with Homebrew:

brew install --cask docker

For other operating systems, download Docker Desktop from docker.com for Windows or install Docker Engine through the package manager on Linux. Installation instructions for different systems are available in Docker’s documentation.

Apple Silicon Mac users (M-series chips) may need to install Rosetta for some container images:

softwareupdate --install-rosetta --agree-to-license

After installing Docker Desktop, open it manually—the Docker daemon doesn’t start automatically on macOS. Launch Docker Desktop from the applications folder. Accept recommendations and provide the system password when prompted. The application can run in the background. On Mac, a whale icon in the menu bar indicates Docker is running.

Verify Docker installation:

docker --version

Docker Terminology

For those unfamiliar with Docker, understanding key terms is essential:

  • Dockerfile: The recipe telling Docker how to build a container image
  • Image: The result of the build process
  • Container: A running instance of an image

Once the application is packaged into an image, that same image runs identically on a laptop, Cloud Run, Kubernetes, or any other platform—same container, same behavior.

Google Cloud Run offers a simpler option: pointing it at source code and letting it figure things out automatically. This will be discussed at the end, but writing a real Dockerfile first provides control and teaches what’s actually happening.

Multi-Stage Builds

The Dockerfile will use a multi-stage build—simpler than it sounds. Two stages exist in one file:

  1. Builder stage: Installs dependencies
  2. Production stage: Starts fresh and copies only what’s needed

The builder stage is discarded after the build, keeping the final image lean.

Do Python packages like psycopg and Pillow require installing PostgreSQL or image processing libraries in the container? No—these packages already bundle everything needed. Start minimal and verify it works.

Creating the Dockerfile

Create a file named Dockerfile (no extension) in the project root:

# BUILD STAGE
FROM python:3.14.4-slim-bookworm AS builder
 
# Copy UV binary from official image
COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /bin/
 
WORKDIR /app
 
# UV Docker optimizations
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV UV_PYTHON_DOWNLOADS=0
 
# Install dependencies first (cached if unchanged)
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project --no-dev
 
# Copy app code and install project
COPY . ./
RUN uv sync --locked --no-dev
 
# PRODUCTION STAGE
FROM python:3.14.4-slim-bookworm
 
WORKDIR /app
 
# Run as non-root user for security
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser
 
# Copy app and dependencies from builder stage
COPY --from=builder --chown=appuser:appuser /app /app
 
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV PORT=8080
 
# exec replaces shell so fastapi receives SIGTERM for clean shutdown
CMD ["/bin/sh", "-c", "exec fastapi run --host 0.0.0.0 --port \"$PORT\" --proxy-headers --forwarded-allow-ips '*'"]

Dockerfile Breakdown

Build Stage:

FROM python:3.14.4-slim-bookworm AS builder

Uses Python 3.14.4 slim bookworm as the base image. The exact version is pinned—tags like 3.14 or latest can change without warning. One day a build works; the next it breaks because the underlying image updated. Pinning ensures reproducible builds.

COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /bin/

Copies the UV binary from Astral’s official Docker image—cleaner than curling an installation script. The version is pinned for the same reproducibility reason.

WORKDIR /app

Sets the working directory inside the container. From this point, all commands and file copies are relative to /app.

ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV UV_PYTHON_DOWNLOADS=0

Sets UV configuration environment variables for Docker builds:

  • UV_COMPILE_BYTECODE=1 precompiles Python files to bytecode at build time. Without this, Python compiles on first import, making the first request slower.
  • UV_LINK_MODE=copy is necessary because UV normally uses hard links to save disk space, but Docker’s layers don’t handle hard links well across stages—files can get lost. Setting this to copy ensures files are actually copied.
  • UV_PYTHON_DOWNLOADS=0 tells UV to use Python from the base image instead of downloading its own.
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project --no-dev

Docker optimization trick: Copy dependency files first and install dependencies before copying application code. Why does order matter? Docker caches each step as a layer. The moment something changes, that step and everything below it rebuilds. If all code copied first, every code change would trigger dependency reinstallation. Placing dependency installation higher means a code change only triggers rebuilds from the code copy step forward—dependency layers above remain cached because pyproject.toml and uv.lock didn’t change.

The uv sync flags:

  • —locked: Use exact versions from the lock file
  • —no-install-project: Don’t install the app yet, just dependencies (code hasn’t been copied)
  • —no-dev: Skip development dependencies like pytest (unnecessary in production)
COPY . ./
RUN uv sync --locked --no-dev

Copy all application code, then run uv sync again to finalize installation. The second run is fast because dependencies are already cached. This time, --no-install-project is omitted, allowing the app to be installed.

Production Stage:

FROM python:3.14.4-slim-bookworm

Start fresh from the same Python base image. All UV binaries and build artifacts from the first stage are gone unless explicitly copied.

WORKDIR /app
 
# Run as non-root user for security
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser

Set the working directory to /app. Create a non-root user called appuser and switch to it. Same security principle as Tutorial 18: if someone exploits the app, they only get appuser privileges, not root.

COPY --from=builder --chown=appuser:appuser /app /app

Multi-stage build in action. --from=builder means copy from the builder stage, not from the local machine. How does Docker know where the builder stage ends and this one begins? Every FROM instruction starts a new stage. The first FROM was labeled AS builder, giving the stage a name for later reference. The second FROM starts a fresh stage. Since it’s the last FROM, this stage becomes the final image.

This line pulls code and the virtual environment with all installed packages from the builder stage. Everything else—UV itself, cached files, temporary build files—stays behind in the builder stage, which gets discarded. That’s the point of two stages: the final image contains only what’s needed to run the app, keeping it small and clean. The --chown ensures files are owned by the non-root user.

ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV PORT=8080

Add the .venv/bin directory to PATH so fastapi, python, etc. can run without full paths. The virtual environment was created by uv sync.

PYTHONUNBUFFERED=1 makes Python output appear immediately in logs instead of being buffered. Without this, print statements and logs might not appear until the buffer fills—confusing when debugging in Cloud Run’s log viewer.

PORT=8080 sets a default port for local testing. Cloud Run overwrites this with its own port when deployed.

CMD ["/bin/sh", "-c", "exec fastapi run --host 0.0.0.0 --port \"$PORT\" --proxy-headers --forwarded-allow-ips '*'"]

Command to run when the container starts. This looks unusual—it’s an array with three parts:

  1. /bin/sh - The program to run (a shell)
  2. -c - Flag telling the shell the next argument is a command to execute
  3. exec fastapi run... - The entire command as a single string

A shell is needed because shells expand environment variables like ${PORT}.

The exec at the beginning is crucial. Without exec, the shell process stays running with the FastAPI app as a child process underneath. When Cloud Run tries gracefully shutting down the container, the shutdown signal goes to the shell, which doesn’t forward it to the app. exec fixes this by replacing the shell with the FastAPI process, so shutdown signals go directly to the app for clean shutdown.

Command flags:

  • —host 0.0.0.0: Listens on all network interfaces. Tutorial 18 used 127.0.0.1 because Nginx was on the same machine. In containers, the network is isolated—use 0.0.0.0 for traffic to reach the app.
  • —port ${PORT}: Uses the port environment variable (8080 by default, overridden by Cloud Run)
  • —proxy-headers —forwarded-allow-ips=’*’: Trust X-Forwarded-* headers from Cloud Run’s load balancer to get the real client IP and protocol

No --workers flag. This is intentional. Cloud Run scales by adding more containers, not more workers within a container. Single process per container means faster cold starts and simpler memory management. Tutorial 18’s VPS set workers based on CPU cores. Here, the autoscaler handles scaling by spinning up more containers as needed.

Creating .dockerignore

.dockerignore tells Docker which files to exclude when building images. A .gitignore file typically contains good starting content for .dockerignore.

Create .dockerignore in the project root:

# Docker-specific exclusions
.git
tests/

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Virtual Environment
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# IDEs
.vscode/
.idea/
*.swp
*.swo
*~

# OS
.DS_Store
Thumbs.db

# Project specific
*.log
.pytest_cache/
.coverage
htmlcov/

Docker-specific exclusions:

  • .git - Git history isn’t needed in containers
  • tests/ - Test files unnecessary in production

Critical exclusions from Git ignore:

  • .venv exclusion is essential. The local virtual environment was built for the local machine’s operating system and architecture. It won’t work inside the container running Linux. The container builds its own virtual environment from scratch using the Dockerfile.
  • .env exclusion is critical for security. The file contains access keys and credentials. Never bake secrets into Docker images—inject them as environment variables at runtime.

Setting Up Neon Database

Docker containers are stateless—any data stored in a container is lost when it stops or restarts. PostgreSQL cannot run inside the Cloud Run container; it would be wiped every restart. An external database accessible over the internet is required.

This tutorial uses Neon, a serverless PostgreSQL service. Neon is not a Google product—it’s an independent company, avoiding vendor lock-in. This isn’t sponsored; it’s simply a popular solution. Any PostgreSQL database works—code wouldn’t change since it’s standard PostgreSQL.

Why Neon?

  • Popular with good community support
  • Free tier: 0.5GB storage and ample compute hours for small projects
  • Scales to zero like Cloud Run (no charges when unused)
  • Standard PostgreSQL (no code changes needed)
  • No credit card required to sign up

Other managed PostgreSQL options exist (AWS RDS, Google Cloud SQL, etc.). Since it’s standard PostgreSQL, code remains identical regardless of provider.

Creating a Neon Project

Visit neon.tech and sign up (free, no credit card). After signing up, the dashboard appears.

Create a new project:

  1. Name: fastapi-blog
  2. Region: US East 1 (choose a region close to where Cloud Run will deploy to minimize latency)
  3. PostgreSQL version: 17 (latest version)

Create the project. After creation, create a database:

From the database dropdown, select “Create new database”:

  • Name: blog
  • Owner: neondb_owner (default)

Create the database.

Connection Strings

Two connection strings are needed:

  1. Direct connection: For running migrations
  2. Pooled connection: For app runtime

Click the “Connect” button in the Neon dashboard. Show the password to see the full string.

Direct Connection (for migrations)

Disable connection pooling. Copy the connection string. It looks like:

psql 'postgresql://username:password@host/blog'

Modify for the application:

  1. Remove the psql wrapper and quotes—just the connection string
  2. Change postgresql:// to postgresql+asyncpg://

Result:

postgresql+asyncpg://username:password@host/blog

The +asyncpg specifies the async driver used throughout the series.

Pooled Connection (for runtime)

Enable connection pooling. Show the password and copy the connection string. Modify the same way (remove psql wrapper, add +asyncpg).

Key difference: The pooled hostname includes -pooler after the host. That’s how to identify a pooled connection.

Why Two Connection Strings?

The pooled connection uses PgBouncer for connection pooling. Cloud Run can scale up multiple container instances rapidly, each opening database connections. Without pooling, connection spikes could overwhelm the database. The pooled endpoint handles many more concurrent connections.

The direct connection is needed for migrations because the pooler can interfere with Alembic operations. Simple rule: use pooled for the app, direct for Alembic migrations.

Running Migrations on Neon

Open .env and add both connection strings (commented out for reference):

# Direct (for migrations)
# DATABASE_URL=postgresql+asyncpg://username:password@host/blog

# Pooled (for runtime)
# DATABASE_URL=postgresql+asyncpg://username:password@host-pooler/blog

Set the direct connection as DATABASE_URL temporarily. Everything else in .env stays the same (secret key, frontend URL, mail settings, S3 credentials).

Best practice: Create two separate environment variables—one for the direct connection Alembic uses, one for the pooled connection the app uses. This avoids manually switching for future maintenance. For this tutorial, switching manually is simpler.

With the direct connection active, run migrations:

uv run alembic upgrade head

Migrations run successfully, creating tables in the Neon database. Verify in the Neon dashboard: navigate to the Tables tab. Tables should include alembic_version, password_reset_tokens, posts, and users.

Switch DATABASE_URL to the pooled connection string (the one for actually running the application). Save .env.

Building and Testing Docker Locally

Before building Docker, perform a sanity check—verify the app works with the Neon database. This isolates potential issues. If this works but Docker fails, the problem is Docker configuration. If this fails, the problem is the Neon connection—fix it before continuing.

Start the development server:

uv run fastapi dev

Visit the application in a browser. The frontend loads (no posts yet—fresh database). Check the health endpoint at /health. It returns status: healthy, confirming the Neon connection works.

Stop the server.

Building the Docker Image

Build the Docker image:

docker build -t fastapi-app .

The -t flag tags the image with the name fastapi-app for easy reference. The . means use the current directory as the build context (where Docker looks for the Dockerfile and application code).

If the build fails with “no interpreter found for Python 3.12”, a .python-version file likely exists locally set to 3.12, conflicting with the Dockerfile’s 3.12.4. Add .python-version to .dockerignore:

# Docker-specific exclusions
.git
tests/
.python-version

Rebuild:

docker build -t fastapi-app .

The build pulls the Python base image, copies the UV binary, installs dependencies, then proceeds through the second stage, copying only what’s needed. The build succeeds.

Running the Container Locally

Run the container using the .env file pointing to Neon:

docker run -p 8080:8080 --env-file .env fastapi-app

Command breakdown:

  • docker run: Runs an image
  • -p 8080:8080: Maps port 8080 on the local machine to port 8080 in the container
  • —env-file .env: Reads environment variables from .env and injects them into the container at runtime (**important: the .env file is not in the image—it stays on the machine because it’s in .dockerignore; variables are only injected when running)
  • fastapi-app: The image to run

The container starts on port 8080 (not 8000 like local development with fastapi dev) because the Dockerfile sets port 8080 by default—what Cloud Run uses.

Test in a browser at http://localhost:8080. The application loads. Check /health—returns status: healthy. Check /docs—documentation works.

Test full functionality: register a user, log in, create a post, upload a profile picture. Everything works because S3 credentials were injected from .env into the container.

If everything works locally in Docker, it should work on Cloud Run—same container, same behavior. That’s why Docker is popular.

Stop the container (Control-C).

Deploying to Google Cloud Run

Creating a Google Cloud Project

Visit console.cloud.google.com and create a Google Cloud Platform account (free tier available). Create a new project:

  • Name: fastapi-blog

Note the Project ID (different from the name)—it’s needed shortly.

Installing gcloud CLI

Install the gcloud CLI. On macOS with Homebrew:

brew install --cask google-cloud-cli

For other systems, download manually from Google’s installation page.

Verify installation:

gcloud --version

Authenticating with Google Cloud

Authenticate:

gcloud auth login

This opens a browser page to log in with a Google account. Review and accept the permissions requested (view and manage applications deployed on Google App Engine, manage Google Cloud SQL instances, etc.). After allowing, the browser displays “You are now authenticated with gcloud CLI.”

Set the project using the Project ID from the dashboard:

gcloud config set project <project-id>

(Replace <project-id> with your actual Project ID)

Enabling Required APIs

Enable APIs that Cloud Run uses:

gcloud services enable run.googleapis.com

This will likely fail if billing hasn’t been enabled. Even for the free tier, Google requires a billing account to be linked. No charges occur if staying within free tier limits. If this fails, navigate to the Google Cloud Console, click Billing, and connect a billing account.

After enabling billing, the command succeeds. Enable two more services:

gcloud services enable cloudbuild.googleapis.com
gcloud services enable artifactregistry.googleapis.com

Verify enabled services:

gcloud services list --enabled

The list should include Cloud Run, Cloud Build, and Artifact Registry.

The three APIs serve specific purposes:

  • Cloud Run: Runs containers
  • Cloud Build: Builds Docker images in the cloud
  • Artifact Registry: Stores Docker images

Creating an Artifact Registry Repository

Create a repository (one-time setup):

gcloud artifacts repositories create fastapi-repo \
  --repository-format=docker \
  --location=us-east4

Location choice: us-east4 is geographically close to the Neon database in us-east1. Keeping services geographically close minimizes latency.

The repository is created.

Building and Pushing the Image

Use Cloud Build to build the image on Google servers and push directly to the registry:

gcloud builds submit \
  --tag us-east4-docker.pkg.dev/<project-id>/fastapi-repo/fastapi-app

Replace <project-id> with your actual Project ID.

Tag URL breakdown:

  • us-east4-docker.pkg.dev: Registry hostname
  • <project-id>: Your project ID
  • fastapi-repo: The repository just created
  • fastapi-app: The image name

This uploads source code to Cloud Build, which builds the image using the Dockerfile on Google’s servers. This is separate from the local build (which was only for testing). The cloud build pushes the resulting image to Artifact Registry.

The first build takes a couple of minutes. Wait for completion.

Deploying to Cloud Run

Deploy the image:

gcloud run deploy fastapi-service \
  --image us-east4-docker.pkg.dev/<project-id>/fastapi-repo/fastapi-app \
  --region us-east4 \
  --allow-unauthenticated

Command breakdown:

  • fastapi-service: Name given to the Cloud Run service
  • —image: Which image to use (from Artifact Registry)
  • —region: us-east4 (same as registry and close to database)
  • —allow-unauthenticated: Anyone can access the app (without this, Google authentication would be required—not desirable for a public blog)

This will fail—expected behavior. The container needs environment variables like DATABASE_URL and SECRET_KEY to boot. Without them, it crashes on startup. The good news: the Cloud Run service has been created. Environment variables just need to be added.

Adding Environment Variables

Multiple methods exist for adding environment variables (gcloud CLI with --set-env-vars for scripting/CI/CD, or the Cloud Console UI). This tutorial uses the UI for visibility.

First, generate a new secret key for production:

python3 -c "import secrets; print(secrets.token_hex(32))"

Copy the output (a long random string). Never reuse keys between environments.

Navigate to Cloud Run in the Google Cloud Console (search “Cloud Run” in the console). The service appears with errors. Click the service name.

Copy the service URL (needed for the FRONTEND_URL environment variable).

Click “Edit and Deploy New Revision.” Expand “Container” and open “Variables & Secrets.” Click “Add Variable.”

Add environment variables one at a time:

Different from .env file:

  • FRONTEND_URL: Use the Cloud Run service URL (not localhost)
  • SECRET_KEY: Use the newly generated key (not the development key)

Same as .env file:

  • DATABASE_URL: Use the pooled connection string for Neon
  • ALGORITHM: HS256
  • ACCESS_TOKEN_EXPIRE_MINUTES: 30
  • S3_BUCKET_NAME: Your bucket name
  • S3_REGION: us-east-1
  • S3_ACCESS_KEY_ID: Your access key
  • S3_SECRET_ACCESS_KEY: Your secret key
  • MAX_UPLOAD_SIZE_BYTES: 5242880
  • POSTS_PER_PAGE: 10
  • RESET_TOKEN_EXPIRE_MINUTES: 60
  • MAIL_SERVER: live.smtp.mailtrap.io
  • MAIL_PORT: 587 (production port for email; works with Mailtrap and will work with production services like SendGrid)
  • MAIL_USERNAME: Your Mailtrap username
  • MAIL_PASSWORD: Your Mailtrap password
  • MAIL_FROM: noreply@yourdomain.com
  • MAIL_USE_TLS: True

After adding all variables, click “Deploy.”

The deployment succeeds this time. Click the service URL. The application loads. The database works (shows the post created during local Docker testing).

Test the health endpoint: /health returns status: healthy.

Test API documentation: /docs loads successfully.

Create a new user, log in, create a post, upload a profile picture. Everything works, including S3 (profile picture loads from an S3 URL).

Viewing Logs

In the Cloud Run console, click “Logs.” Application logs appear in the console. The PYTHONUNBUFFERED=1 setting in the Dockerfile ensures logs appear immediately without delays.

Setting Up a Custom Domain

The generated Cloud Run URL (fastapi-service-<hash>.run.app) works but isn’t user-friendly. Configure a custom domain.

Important caveat: Cloud Run’s built-in domain mapping is currently labeled “preview” and “limited availability.” Google states it’s not recommended for production. For real production apps, Google recommends using an external load balancer, Firebase hosting, or Cloudflare in front of Cloud Run. For this tutorial, built-in domain mapping keeps things simple and demonstrates the full process. It’s expected to exit preview eventually.

Verifying Domain Ownership

Visit Google Search Console and add a property. Select “Domain” (not “URL prefix”).

Enter the domain (e.g., myawesomeapp.com) and click Continue.

Google may auto-verify if the domain was previously verified on the account. Otherwise, Google provides a TXT record to add to DNS settings.

If a TXT record is provided:

  1. Navigate to your domain registrar (Namecheap, GoDaddy, etc.)
  2. Find DNS settings (might be called “Advanced DNS,” “DNS Management,” or “DNS Records”)
  3. Add a new TXT record:
    • Host: @
    • Value: The string Google provided
  4. Save the record
  5. Wait a few minutes for DNS propagation
  6. Return to Search Console and click “Verify”

Creating the Domain Mapping

Create the domain mapping (requires the beta command since it’s in preview):

gcloud beta run domain-mappings create \
  --service fastapi-service \
  --domain myawesomeapp.com \
  --region us-east4

(Replace myawesomeapp.com with your domain and us-east4 with your region)

Cloud Run provides DNS records to add: four A records for IPv4 and four AAAA records for IPv6.

Adding DNS Records

Navigate to the domain registrar’s DNS settings. Delete any existing A records pointing elsewhere (from previous tutorials, etc.).

Add the records Cloud Run provided:

  • Add three A records with the IPs provided
  • Add four AAAA records with the IPs provided
  • For all records, use @ as the host (representing the root domain)

Save all records.

Waiting for SSL Certificate Provisioning

DNS propagation and SSL certificate provisioning take time. Unlike Tutorial 18 where Certbot provisioned certificates in about a minute, Cloud Run’s automated system provisions certificates on its own schedule. Expect 15-45 minutes, sometimes longer.

Check certificate status:

gcloud beta run domain-mappings describe \
  --domain myawesomeapp.com \
  --region us-east4

Initially, this shows “Waiting for certificate provisioning. You must configure DNS settings for certificate issuance to begin.”

After waiting (approximately 50 minutes in this case), rerun the command. Once certificateProvisioned: true appears, the certificate is ready.

Visit the custom domain in a browser. The application loads with a secure connection (HTTPS with a valid certificate).

Test functionality: health check works, create a new account, log in, create a post, upload a profile picture. Everything functions on the custom domain with S3 working correctly.

Updating the Application

When making code changes, updating the deployed application requires two commands:

  1. Rebuild and push the image:
gcloud builds submit \
  --tag us-east4-docker.pkg.dev/<project-id>/fastapi-repo/fastapi-app

The Dockerfile copies application code during build. This command picks up code changes, builds a new image with those changes, and pushes it to the registry.

  1. Deploy the new version:
gcloud run deploy fastapi-service \
  --image us-east4-docker.pkg.dev/<project-id>/fastapi-repo/fastapi-app \
  --region us-east4 \
  --allow-unauthenticated

Cloud Run handles zero-downtime deployment. It spins up the new version, verifies health checks pass, then routes traffic to it. If health checks fail, traffic stays on the previous version.

For database schema changes: Run migrations with Alembic. Temporarily swap to the direct connection string in .env, run alembic upgrade head, then swap back. (If using separate variables for direct and pooled connections, migrations run without swapping.)

Deploying from Source (Alternative)

Cloud Run can deploy directly from source code without separate build and deploy commands:

gcloud run deploy fastapi-service \
  --source . \
  --region us-east4

The --source flag uploads source code and builds it on Google’s servers. If a Dockerfile exists (it does), Cloud Run uses it automatically. This combines build and deploy into one convenient command.

If no Dockerfile existed, Cloud Run falls back to Buildpacks—Google’s tool that auto-detects the project language and builds a container automatically with no Dockerfile needed.

Why write a complete Dockerfile if Buildpacks handles it automatically?

Two reasons:

  1. Version support: When preparing this tutorial, Buildpacks didn’t support the Python version used in the series. Google has since updated it, but that illustrates the point. With a Dockerfile, full control exists over Python version, package manager, startup process, and everything else—no waiting for Google to support specific setups.

  2. Docker is a fundamental transferable skill. The Dockerfile written works on Cloud Run, AWS, Kubernetes, CI/CD pipelines, anywhere. Buildpacks is Google-specific. If it works for a use case, it’s a great shortcut, but knowing Docker provides control and portability.

Cost Comparison: VPS vs. Cloud Run

Cloud Run Free Tier:

  • 2 million requests per month
  • Scales to zero when no traffic (no charges when unused)

Neon Free Tier:

  • 0.5GB storage
  • Ample compute hours for small projects
  • Scales to zero when idle

Total cost for a small project: likely $0. Both services scale to zero. If traffic increases significantly, charges begin—but that’s a good problem, indicating a successful application.

VPS (from Tutorial 18):

  • ~$5/month for the smallest VPS (whether anyone visits or not)
  • Full management responsibility
  • Predictable cost

Deployment Comparison

VPSManaged Container Platform (Cloud Run)
ControlFull controlManage only the container
MaintenanceManual OS, Nginx, PostgreSQL updatesGoogle and Neon handle infrastructure
ScalingManual server upgradesAutomatic scaling
CostFixed ($5/month)Pay-per-use (likely $0 for small apps)
SkillsLinux administration, NginxDocker, cloud provider tools (gcloud)

When to choose each:

  • VPS: Predictable traffic, learning system administration fundamentals
  • Managed container platform: Variable traffic, less operational overhead

Both are legitimate production choices—the decision depends on the situation.

Series Conclusion

FastAPI Cloud (FastAPI’s own deployment offering) is currently in private beta. Once released, an additional tutorial may cover deployment through it.

This series has built a complete blog application from scratch: authentication, API documentation, templates, file uploads, email, pagination, database migrations, testing, AWS S3 images, and two production deployments (VPS and containerized cloud). This is a real end-to-end project. Completing it provides strong preparation for building and shipping applications.

Future videos will cover topics requested by Patreon supporters and channel members, including rate limiting and a React frontend. Subscribe to avoid missing those additions.