Production AI Infrastructure

What `ghcr.io` Is, and How to Use It with Helm and Argo CD for a FastAPI App

A practical, source-backed guide to GitHub Container Registry, OCI Helm charts, Argo CD, and a clean local/dev/prod flow for a simple FastAPI service.

27 min read

ghcr.io shows up in a lot of modern deployment examples, but many guides skip the most important question: what is it actually doing in the flow?

If you are building a simple FastAPI app, the confusion usually looks like this:

  • local development uses Docker Compose
  • CI builds an image and pushes it somewhere
  • Helm appears for Kubernetes
  • Argo CD appears for GitOps
  • and suddenly nobody is fully sure which system is responsible for what

This guide is meant to clear that up for both engineers and PMs. The examples are simple on purpose, but the deployment model is production-minded.

The research below was reviewed against primary documentation on April 8, 2026.

TL;DR

  • ghcr.io is the hostname for GitHub Container Registry. It stores OCI-compatible artifacts, which means it can hold your container images and OCI-packaged Helm charts.
  • For a FastAPI service, a clean mental model is: local with Docker Compose, dev on merge to main, and prod on Git tag.
  • GHCR is a registry, not a deployment system. Pushing an image or chart to GHCR does not deploy by itself.
  • Helm should define the runtime contract for Kubernetes. Argo CD should reconcile what is in Git to what is in the cluster.
  • The most reliable setup is to keep images and charts in GHCR, and keep environment intent in Git so Argo CD can sync it.
  • For dev, automate on main merges. For prod, automate on semantic version tags such as v1.4.0.
  • Use immutable image references for actual deployments, even if you also publish friendly moving tags like main.

What you’ll learn here

  • what ghcr.io actually is
  • how GHCR fits with Docker images and Helm charts
  • how to structure a simple FastAPI repo for local, dev, and prod
  • how Docker Compose, Helm, and Argo CD should divide responsibilities
  • how to automate dev deployments on merge to main
  • how to automate prod deployments on Git tags
  • which shortcuts improve developer experience, and which shortcuts create future pain

The delivery model we are aiming for

Here is the full picture before we dive into tools:

Local laptop
  |
  | docker compose up --watch
  v
FastAPI app runs locally

Merge to main
  |
  v
GitHub Actions
  |- build image
  |- push image to ghcr.io
  |- package Helm chart
  |- push chart to ghcr.io
  `- update GitOps repo for dev
          |
          v
      Argo CD notices Git change
          |
          v
      dev namespace syncs

Create tag v1.2.0
  |
  v
GitHub Actions
  |- build release image
  |- push image to ghcr.io
  |- package release Helm chart
  |- push chart to ghcr.io
  `- update GitOps repo for prod
          |
          v
      Argo CD notices Git change
          |
          v
      prod namespace syncs

That flow is opinionated in one important way:

GHCR stores artifacts. Git stores deployment intent. Argo CD applies that intent.

That separation keeps the system understandable when more people join the project.

What ghcr.io actually is

ghcr.io is the registry endpoint for GitHub Container Registry, which is part of GitHub Packages.

In practical terms, it gives you a place to publish artifacts like these:

ArtifactExample
Docker imageghcr.io/acme/hello-fastapi:sha-abc1234
Human-friendly image tagghcr.io/acme/hello-fastapi:main
OCI Helm chartoci://ghcr.io/acme/charts/hello-fastapi --version 1.2.0

What makes GHCR especially convenient for GitHub-based teams:

  • GitHub documents support for Docker and OCI images in the container registry.
  • The registry supports granular permissions.
  • Public images can be pulled anonymously.
  • Inside GitHub Actions, the workflow can publish packages associated with the same repository by using GITHUB_TOKEN.

So if your code already lives in GitHub, GHCR removes a lot of friction:

  • no second vendor is required just to host images
  • auth in Actions is straightforward
  • chart and image hosting can live close to the repo that produced them

The key nuance is this:

GHCR is not Helm and it is not Argo CD.

  • GHCR answers: “Where do the built artifacts live?”
  • Helm answers: “How should this app run on Kubernetes?”
  • Argo CD answers: “Does the cluster match what Git says should run?”

Once you separate those jobs, the architecture gets much easier to reason about.

The repo layout I would recommend

For a small team, I like this split:

hello-fastapi/
├── app/
│   └── main.py
├── charts/
│   └── hello-fastapi/
├── compose.yaml
├── Dockerfile
└── .github/
    └── workflows/

hello-fastapi-env/
├── apps/
│   ├── dev.yaml
│   └── prod.yaml
└── envs/
    ├── dev/values.yaml
    └── prod/values.yaml

Why two repos?

  • The application repo owns code, image build, and chart packaging.
  • The environment repo owns deployment intent for dev and prod.
  • Argo CD watches the environment repo, which keeps GitOps simple.

You can absolutely keep everything in one repo when a team is small. But once dev and prod promotion rules become real, a separate environment repo usually pays off quickly.

Local development: keep it boring and fast with Docker Compose

For local work, the goal is not to simulate the entire cluster. The goal is to make it fast to edit, run, and validate the app.

A minimal FastAPI app:

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def root():
    return {"message": "hello from FastAPI"}


@app.get("/healthz")
def healthz():
    return {"status": "ok"}

A simple Dockerfile:

FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

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

COPY app ./app

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

And a local compose.yaml with good development ergonomics:

services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      APP_ENV: local
    healthcheck:
      test:
        [
          "CMD",
          "python",
          "-c",
          "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')",
        ]
      interval: 10s
      timeout: 5s
      retries: 5
    develop:
      watch:
        - action: sync
          path: ./app
          target: /app/app
          initial_sync: true
        - action: rebuild
          path: ./requirements.txt

  mailhog:
    image: mailhog/mailhog:latest
    profiles: ["debug"]
    ports:
      - "8025:8025"

Run the normal path:

docker compose up --watch

Or turn on optional tools only when you need them:

docker compose --profile debug up --watch

That pattern lines up nicely with current Docker documentation:

  • watch improves the hands-off local loop for services built from source
  • profiles let you keep optional tooling in the same Compose file
  • health checks matter because Compose does not assume “running” means “ready”

For local development, this is plenty. You do not need Helm or Argo CD on your laptop just to edit one API endpoint.

Helm: package the Kubernetes contract, not just some YAML

Once the app leaves the laptop, Helm becomes useful because it defines the runtime contract for Kubernetes.

Typical chart structure:

charts/hello-fastapi/
├── Chart.yaml
├── values.yaml
└── templates/
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    └── serviceaccount.yaml

Minimal Chart.yaml:

apiVersion: v2
name: hello-fastapi
description: A Helm chart for a simple FastAPI app
type: application
version: 1.2.0
appVersion: 1.2.0

Minimal values.yaml:

image:
  repository: ghcr.io/acme/hello-fastapi
  tag: "1.2.0"
  pullPolicy: IfNotPresent

service:
  port: 8000

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

env:
  APP_ENV: prod

And a useful Deployment fragment:

containers:
  - name: api
    image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
    imagePullPolicy: {{ .Values.image.pullPolicy }}
    ports:
      - name: http
        containerPort: 8000
    env:
      - name: APP_ENV
        value: {{ .Values.env.APP_ENV | quote }}
    readinessProbe:
      httpGet:
        path: /healthz
        port: http
    livenessProbe:
      httpGet:
        path: /healthz
        port: http

This is where Helm earns its keep:

  • image reference is centralized
  • dev and prod values stay explicit
  • the chart becomes the reusable deployable contract

Using GHCR for OCI Helm charts

Helm supports OCI-based registries, and that means your chart can live in GHCR alongside the app image.

The basic flow is:

helm registry login ghcr.io -u YOUR_GITHUB_USERNAME
helm package charts/hello-fastapi --destination .dist
helm push .dist/hello-fastapi-1.2.0.tgz oci://ghcr.io/acme/charts

A few details matter here:

  • the push target must start with oci://
  • helm push infers the chart name from Chart.yaml
  • the chart tag must match the chart semantic version
  • for download and install commands, the chart name is part of the reference

So later, a pull looks like this:

helm pull oci://ghcr.io/acme/charts/hello-fastapi --version 1.2.0

And a local render looks like this:

helm template hello-fastapi oci://ghcr.io/acme/charts/hello-fastapi --version 1.2.0

That gives you a clean separation:

  • image artifact in GHCR
  • chart artifact in GHCR
  • environment values in Git

Argo CD: Git should trigger deployment intent

This is the part that often gets oversimplified.

People sometimes describe the system like this:

push image to registry -> Argo CD magically deploys it

That is incomplete.

Argo CD’s own automated sync model is Git-centric. It automatically syncs when it detects differences between the desired manifests in Git and the live cluster state. In other words, the registry stores artifacts, but Git should still express what revision an environment should use.

That is why I recommend:

  • GHCR for images and charts
  • Git for dev/prod deployment intent
  • Argo CD auto-sync from Git

Argo CD configuration for a Helm chart stored in GHCR

For a private GHCR-backed chart repository, Argo CD needs repository credentials with OCI enabled:

apiVersion: v1
kind: Secret
metadata:
  name: ghcr-helm
  namespace: argocd
  labels:
    argocd.argoproj.io/secret-type: repository
stringData:
  url: ghcr.io/acme/charts
  type: helm
  enableOCI: "true"
  username: gh-bot
  password: ${GHCR_TOKEN}

Then the Application can point to the OCI Helm chart and pull values from a Git repo:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: hello-fastapi-dev
  namespace: argocd
spec:
  project: default
  sources:
    - repoURL: ghcr.io/acme/charts
      chart: hello-fastapi
      targetRevision: 0.1.0-dev.128
      helm:
        valueFiles:
          - $values/envs/dev/values.yaml
    - repoURL: https://github.com/acme/hello-fastapi-env.git
      targetRevision: main
      ref: values
  destination:
    server: https://kubernetes.default.svc
    namespace: hello-fastapi-dev
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    retry:
      limit: 5
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m

Two nuances are easy to miss:

  • When Argo CD uses the Helm chart field for OCI charts, the repoURL does not include the oci:// prefix.
  • Argo CD can source Helm values from a separate repository, which is extremely useful for GitOps environment repos.

That gives you a very clean promotion story:

  • chart lives in GHCR
  • image lives in GHCR
  • environment values live in Git
  • Argo CD syncs after Git changes

The environment strategy I recommend

Here is the practical split:

EnvironmentTriggerWhat gets builtWhat Argo CD deploys
localdocker compose up --watchlocal image onlynothing in cluster
devmerge to mainimage + dev chart packageexact dev chart version plus exact image tag in Git
prodpush tag like v1.2.0release image + release chart packageexact release version in Git

The important part is the word exact.

For deployment identity:

  • use immutable image tags such as sha-<git-sha> or semantic versions
  • use semantic chart versions
  • let Git point each environment at a precise version

You can still publish convenience tags like main, but do not make them the source of truth for production.

Dev automation: merge to main

For development, the smoothest workflow is:

  1. A PR merges to main.
  2. GitHub Actions builds the image.
  3. The workflow pushes the image to GHCR with:
    • an immutable sha-<commit> tag
    • an optional moving main tag for humans
  4. The workflow packages a dev chart version such as 0.1.0-dev.128.
  5. The workflow pushes the chart to GHCR.
  6. The workflow updates the GitOps repo for dev.
  7. Argo CD auto-syncs dev.

A compact workflow skeleton:

name: build-dev

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  publish:
    runs-on: ubuntu-latest
    env:
      REGISTRY: ghcr.io
      IMAGE_NAME: ${{ github.repository }}
      CHART_REPO: oci://ghcr.io/${{ github.repository_owner }}/charts
    steps:
      - uses: actions/checkout@v5

      - name: Compute versions
        id: vars
        run: |
          echo "image_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
          echo "chart_version=0.1.0-dev.${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.image_tag }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Package and push chart
        run: |
          CHART_VERSION="${{ steps.vars.outputs.chart_version }}"
          mkdir -p .dist
          sed \
            -e "s/^version: .*/version: ${CHART_VERSION}/" \
            -e "s/^appVersion: .*/appVersion: ${GITHUB_SHA}/" \
            charts/hello-fastapi/Chart.yaml > /tmp/Chart.yaml
          cp /tmp/Chart.yaml charts/hello-fastapi/Chart.yaml
          helm package charts/hello-fastapi --destination .dist
          helm push .dist/hello-fastapi-${CHART_VERSION}.tgz $CHART_REPO

      - name: Update GitOps repo for dev
        run: ./scripts/promote-env.sh dev \
          "${{ steps.vars.outputs.image_tag }}" \
          "${{ steps.vars.outputs.chart_version }}"

In a real repo, I would pin actions to commit SHAs even if the example above uses major tags for readability.

Prod automation: create a Git tag

For production, the event is not a merge. It is a release signal.

I recommend using semantic version tags:

git tag v1.2.0
git push origin v1.2.0

Then the workflow should:

  1. derive 1.2.0 from v1.2.0
  2. build and push ghcr.io/acme/hello-fastapi:1.2.0
  3. also push sha-<commit> for traceability
  4. package chart version 1.2.0
  5. push the chart to GHCR
  6. update the GitOps repo for prod
  7. let Argo CD sync prod

Example skeleton:

name: release-prod

on:
  push:
    tags:
      - "v*.*.*"

permissions:
  contents: read
  packages: write

jobs:
  release:
    runs-on: ubuntu-latest
    env:
      REGISTRY: ghcr.io
      IMAGE_NAME: ${{ github.repository }}
      CHART_REPO: oci://ghcr.io/${{ github.repository_owner }}/charts
    steps:
      - uses: actions/checkout@v5

      - name: Compute release version
        id: vars
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push release image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.version }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Package and push release chart
        run: |
          VERSION="${{ steps.vars.outputs.version }}"
          mkdir -p .dist
          sed \
            -e "s/^version: .*/version: ${VERSION}/" \
            -e "s/^appVersion: .*/appVersion: ${VERSION}/" \
            charts/hello-fastapi/Chart.yaml > /tmp/Chart.yaml
          cp /tmp/Chart.yaml charts/hello-fastapi/Chart.yaml
          helm package charts/hello-fastapi --destination .dist
          helm push .dist/hello-fastapi-${VERSION}.tgz $CHART_REPO

      - name: Update GitOps repo for prod
        run: ./scripts/promote-env.sh prod \
          "${{ steps.vars.outputs.version }}" \
          "${{ steps.vars.outputs.version }}"

If your team wants an approval gate for production, make that last step open a PR in the environment repo instead of committing directly. For many teams, that is the better default.

The one idea that keeps this sane

There are two clean ways to automate updates after artifacts land in GHCR:

Option A: CI updates the GitOps repo

This is the simplest model to explain and support.

  • CI publishes artifacts
  • CI commits the desired version into Git
  • Argo CD syncs from Git

This is usually my default recommendation for small and medium teams.

Option B: Argo CD Image Updater writes back to Git

This is useful if you want Argo CD to watch the registry more directly while still preserving Git as the durable source of truth.

Current Image Updater docs support:

  • digest strategy for mutable tags like main
  • semver strategy for release tags
  • Git write-back so updates are persisted into Git instead of only changing the live Application

If you go this route, use the Git write-back method rather than the direct argocd write-back method for long-lived environments. It is more GitOps-friendly and more durable.

Where should you deploy this app?

This is where many teams accidentally mix up two different questions:

  1. “What is the easiest and cheapest place to run a simple FastAPI app?”
  2. “What is the best place to preserve the exact GHCR + Helm + Argo CD workflow from this article?”

Those questions do not always lead to the same platform.

If your main goal is simple hosting, platforms like Render, Railway, or Cloud Run are usually easier.

If your main goal is Kubernetes GitOps with Helm and Argo CD, managed Kubernetes options such as EKS or GKE are the cleaner fit.

Cost and complexity quick comparison

These numbers are not full production totals. They are official entry points or official pricing examples reviewed on April 8, 2026. Real monthly spend also depends on database choice, storage, traffic, logs, load balancers, region, and team plan.

OptionMonthly cost signal from official docsComplexityBest fitFit for GHCR + Helm + Argo CD
Cloudflare WorkersPaid plan has a $5/month minimum, includes 10 million requests and 30 million CPU ms, with no additional Workers egress chargesLow to mediumEdge-first APIs and lightweight FastAPI apps that fit the Workers modelLow
Cloudflare ContainersIncluded in Workers Paid with 25 GiB-hours, 375 vCPU-minutes, and 200 GB-hours, then usage-based billing; container egress is billed separately by regionMediumContainer workloads on Cloudflare when you want a more container-like runtime than WorkersLow to medium
Render Web ServiceHobby workspace is $0/user/month plus compute; web services start at $0 and the first paid web service tier is $7/monthLowFastest developer experience for a small team shipping a Dockerized APILow
RailwayFree is $0/month; Hobby is $5/month and includes $5 of usage; Pro is $20/month and includes $20 of usage. Private registries like GHCR require ProLowSmall teams that want strong DX, usage-based billing, Docker flexibility, and optional GHCR image deploysLow to medium
AWS App RunnerOfficial examples show $4.80/month for a paused dev/test app and $25.50/month for a lightweight latency-sensitive APIMediumTeams already on AWS that want managed containers without operating KubernetesLow
AWS EKS$0.10/cluster-hour for standard support, which is roughly $73/month before worker nodes and other AWS resourcesHighTeams that really want Kubernetes, Helm, Argo CD, and GitOps on AWSHigh
Google Cloud RunRequest-based free tier includes 2 million requests, 180,000 vCPU-seconds, and 360,000 GiB-seconds; official examples show $13.69/month for a 10M-request API and $7.25/month for a lightweight event-like serviceLow to mediumSimple containerized APIs with scale-to-zero and low ops overheadLow
Google GKE$0.10/cluster-hour cluster management fee, which is roughly $74.40/month before node and networking costsHighTeams that want Kubernetes GitOps on Google CloudHigh

What this means in practice

Need Helm + Argo CD + Kubernetes?
  |
  +-- yes --> EKS or GKE
  |
  +-- no --> Want fastest path for a normal containerized API?
            |
            +-- yes --> Render, Railway, or Cloud Run
            |
            +-- no --> Need edge-first deployment and accept a different runtime model?
                      |
                      +-- yes --> Cloudflare Workers

Cloudflare

Cloudflare is the most interesting “it depends” option in this list.

Cloudflare now officially supports FastAPI in Python Workers. Their docs explicitly say the FastAPI package is supported in Python Workers and that the Workers runtime provides an ASGI server directly to the Worker.

That means Cloudflare can be very attractive when you want:

  • a globally distributed API
  • extremely simple deploys
  • low starting cost
  • an edge-first model

But there is an architectural catch:

  • this path is not naturally Docker-first
  • it does not naturally preserve the Helm + Argo CD path from this article
  • some Python package assumptions may need rethinking compared with a normal Linux container

My take:

  • choose Cloudflare Workers if you want the simplest edge runtime and your FastAPI app fits the Workers model
  • choose Cloudflare Containers if you want a more container-like runtime on Cloudflare
  • do not choose Cloudflare just because the article uses GHCR, Helm, and Argo CD, because Cloudflare is strongest when you simplify away from that stack

Render

Render is probably the easiest “normal app platform” option for this exact FastAPI example.

Why it is attractive:

  • very low platform complexity
  • straightforward Docker support
  • Git-based auto deploys
  • preview environment support
  • low entry cost for small apps

Why it is less aligned with the architecture in this article:

  • you usually do not need Helm there
  • you usually do not need Argo CD there
  • GHCR can still be useful, but Render can also build and deploy directly from Git or Docker

My take:

  • choose Render if your priority is shipping a small FastAPI app quickly with excellent developer experience
  • avoid forcing Kubernetes patterns onto Render unless you already know you will outgrow it soon

Railway

Railway is a very credible alternative here, especially if you want something closer to Render in simplicity but with a more explicitly usage-based pricing model.

Why it is attractive:

  • low platform complexity
  • usage-based billing with simple entry tiers
  • GitHub-connected autodeploys
  • a Wait for CI option so deployments can wait for GitHub Actions to pass
  • health checks that wait for a 200 response before switching traffic
  • support for private registries, including GHCR

What makes Railway especially relevant to this article is that it can sit in the middle:

  • simpler than Kubernetes
  • more deployment-flexible than some “push from Git only” platforms
  • capable of pulling private images from GHCR when you are on the Pro plan

That means Railway is one of the stronger alternatives if your workflow is:

  • build image in GitHub Actions
  • publish image to GHCR
  • deploy that image to a managed app platform

But there are still important limits:

  • Helm and Argo CD are not natural parts of the Railway workflow
  • private GHCR pulls require the Pro plan
  • if your service has an attached volume, Railway documents that redeploys will have a small amount of downtime to avoid data corruption

My take:

  • choose Railway if you want a strong developer experience, Docker or GHCR-based deploy flexibility, and you do not actually need Kubernetes
  • choose Railway over Render if you prefer the usage-based model or specifically want a cleaner path from GHCR image to deployment
  • do not choose Railway if the whole point is to preserve the Helm + Argo CD + Kubernetes architecture from this article

AWS

AWS really gives you two honest choices here:

AWS App Runner

App Runner is the simpler path.

It is a good fit when you want:

  • AWS as the cloud provider
  • a managed container platform
  • no Kubernetes control plane to operate
  • a cleaner path from Docker image to running service

It is a strong choice for a simple FastAPI API, but it is not the natural home for Helm + Argo CD.

AWS EKS

EKS is the better fit when the article’s architecture is the actual goal.

Choose it when you want:

  • Kubernetes as a platform standard
  • Helm as the configuration contract
  • Argo CD as the GitOps reconciler
  • a future with multiple services, namespaces, environments, and platform patterns

The downside is cost and complexity:

  • there is a real cluster management fee before compute
  • you still pay for nodes, load balancers, storage, and surrounding AWS services
  • platform setup and operations are meaningfully heavier than App Runner

My take:

  • choose AWS App Runner for a simple service on AWS
  • choose AWS EKS only when you truly want the Kubernetes + GitOps operating model

Google Cloud

Google Cloud has a very similar split:

Cloud Run

Cloud Run is the easier path for a simple FastAPI app.

It is attractive when you want:

  • container deploys without Kubernetes operations
  • scale-to-zero economics
  • fast path from code to running service
  • a lower monthly floor for modest workloads

For many small APIs, this is the most cost-efficient managed option in this comparison.

GKE

GKE is the stronger fit when you want the full Kubernetes workflow from this article.

Choose it when you want:

  • Helm charts as a first-class deployment contract
  • Argo CD GitOps
  • a standard Kubernetes operating model on Google Cloud

Just like EKS, the tradeoff is that the cluster itself has a management cost before your workload costs even start.

My take:

  • choose Cloud Run for the simpler container platform
  • choose GKE when Kubernetes is the real product decision, not just an implementation detail

My honest recommendation by stage

  • If this is an MVP or small internal service, pick Render, Railway, or Cloud Run first.
  • If you are edge-first and your FastAPI app fits the runtime, Cloudflare Workers is worth serious consideration.
  • If your team already operates Kubernetes and wants GitOps, choose EKS or GKE.
  • If the main reason you are considering Kubernetes is “because it feels more production-grade,” that is usually not enough reason on its own.

If you do want Kubernetes, what infrastructure options are common?

If the conclusion is “yes, we really do want Kubernetes,” the next question is not Helm or Argo CD. The next question is which Kubernetes substrate should we run on.

For most teams, the common shortlist looks like this:

OptionTypeBest fitMy take
Amazon EKSManaged KubernetesAWS-first teams that want deep AWS integrationStrong choice when the rest of your platform is already on AWS
Google GKEManaged KubernetesTeams on Google Cloud that want a polished managed Kubernetes experienceOne of the cleanest managed Kubernetes experiences if GCP is already home base
Azure AKSManaged KubernetesAzure-first teams and Microsoft-heavy organizationsA very reasonable default if your company already runs on Azure
DigitalOcean Kubernetes (DOKS)Managed KubernetesSmaller teams that want simpler managed Kubernetes with fewer cloud primitives to learnA good “I want Kubernetes, but not the whole hyperscaler tax” option
K3s on VPS or bare metalSelf-managed lightweight KubernetesLabs, edge, internal platforms, cost-sensitive teams, and teams that truly want full controlExcellent when you want lightweight Kubernetes and can own more of the platform work yourself

How I would choose

Amazon EKS

Choose EKS when:

  • your infrastructure is already AWS-heavy
  • you want native alignment with AWS IAM, networking, storage, and observability patterns
  • platform engineers on your team already think in AWS primitives

EKS is a strong production default, but it brings the normal Kubernetes platform tax with it.

Google GKE

Choose GKE when:

  • your workloads already live in Google Cloud
  • you want a mature managed Kubernetes platform
  • your team values a strong out-of-the-box Kubernetes experience more than deep AWS or Azure-specific integrations

If you already picked Cloud Run for simpler services, GKE is the natural Google Cloud step up when you outgrow that model.

Azure AKS

Choose AKS when:

  • your organization is standardized on Azure
  • identity, networking, and operational standards already revolve around Microsoft tooling
  • you want managed Kubernetes without leaving the Azure ecosystem

Microsoft’s AKS docs frame it as a managed Kubernetes service that reduces operational overhead, and the overview docs explicitly note that Azure manages the control plane for you.

DigitalOcean Kubernetes

Choose DigitalOcean Kubernetes when:

  • you want real Kubernetes, not just a container platform
  • your team is small and wants a simpler cloud surface area
  • you still want standard Kubernetes tooling like kubectl, autoscaling, load balancers, and volumes

DigitalOcean’s docs describe DOKS as fully managed control plane Kubernetes with high availability and autoscaling, and it integrates with standard Kubernetes toolchains. That makes it a nice middle ground between “full hyperscaler platform” and “I guess we self-manage now.”

K3s on VPS or bare metal

Choose K3s when:

  • you want lightweight Kubernetes
  • you are deploying at the edge, in a homelab, on internal hardware, or on simple VMs
  • you care more about operational control and lower raw infra cost than fully managed convenience

K3s is explicitly positioned as lightweight Kubernetes and is a great fit for edge, CI, development, single-board computers, air-gapped environments, and other situations where full-fat Kubernetes would be overkill.

The tradeoff is clear:

  • you own more of the cluster lifecycle
  • you own more of networking, storage, upgrades, and recovery
  • you should only choose this path if that ownership is intentional

My practical shortlist

  • Pick EKS if you are already on AWS.
  • Pick GKE if you are already on Google Cloud.
  • Pick AKS if your company is already committed to Azure.
  • Pick DigitalOcean Kubernetes if you want managed Kubernetes with a smaller platform footprint.
  • Pick K3s only when you intentionally want self-managed lightweight Kubernetes.

Best practices that improve developer experience without hurting production

  • Publish both a human-friendly tag and an immutable tag, but deploy with the immutable one.
  • Keep local development on Compose. Do not require Kubernetes to change a route handler.
  • Use docker compose up --watch for the local inner loop.
  • Keep optional local-only tools behind Compose profiles.
  • Put chart defaults in values.yaml, and keep environment overrides in explicit envs/dev/values.yaml and envs/prod/values.yaml files.
  • Let Argo CD auto-sync dev aggressively.
  • Treat prod promotions as explicit release events, ideally semantic version tags.
  • Keep CI away from direct cluster credentials when Argo CD can pull from Git instead.
  • If GHCR artifacts are private, use dedicated pull credentials for Argo CD and grant only the access needed.
  • Use GitHub’s GITHUB_TOKEN for same-repo package publication in Actions when possible.

Common mistakes

  • Treating the registry as the source of truth instead of Git.
  • Deploying latest, main, or dev directly to prod.
  • Mixing chart versioning and image versioning without a policy.
  • Using Helm only as string templating, with no clear values contract.
  • Building different images for local and cluster without understanding the drift.
  • Making local development depend on the entire production platform.

If you want one practical answer, it is this:

  1. Keep FastAPI local development on Docker Compose with watch.
  2. Push application images to ghcr.io/<owner>/<repo>.
  3. Push OCI Helm charts to oci://ghcr.io/<owner>/charts.
  4. Keep Argo CD Application manifests and environment values in a GitOps repo.
  5. On merge to main, auto-publish a dev image and chart, then auto-update dev.
  6. On vX.Y.Z tags, auto-publish a release image and chart, then promote prod.

That is simple enough for a small team, clear enough for PMs to follow, and strong enough to grow with.

Sources

Verified on April 8, 2026 against primary documentation:

Some recommendations in this article are engineering inferences built on top of the documentation above. In particular, the recommendation to keep Git as the durable deployment source of truth, while using GHCR as the artifact registry for both images and charts, is an architectural judgment rather than a direct vendor requirement.