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.iois 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
mainmerges. For prod, automate on semantic version tags such asv1.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.ioactually 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:
| Artifact | Example |
|---|---|
| Docker image | ghcr.io/acme/hello-fastapi:sha-abc1234 |
| Human-friendly image tag | ghcr.io/acme/hello-fastapi:main |
| OCI Helm chart | oci://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:
watchimproves the hands-off local loop for services built from sourceprofileslet 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 pushinfers the chart name fromChart.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
chartfield for OCI charts, therepoURLdoes not include theoci://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:
| Environment | Trigger | What gets built | What Argo CD deploys |
|---|---|---|---|
| local | docker compose up --watch | local image only | nothing in cluster |
| dev | merge to main | image + dev chart package | exact dev chart version plus exact image tag in Git |
| prod | push tag like v1.2.0 | release image + release chart package | exact 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:
- A PR merges to
main. - GitHub Actions builds the image.
- The workflow pushes the image to GHCR with:
- an immutable
sha-<commit>tag - an optional moving
maintag for humans
- an immutable
- The workflow packages a dev chart version such as
0.1.0-dev.128. - The workflow pushes the chart to GHCR.
- The workflow updates the GitOps repo for
dev. - 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:
- derive
1.2.0fromv1.2.0 - build and push
ghcr.io/acme/hello-fastapi:1.2.0 - also push
sha-<commit>for traceability - package chart version
1.2.0 - push the chart to GHCR
- update the GitOps repo for
prod - 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:
digeststrategy for mutable tags likemainsemverstrategy 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:
- “What is the easiest and cheapest place to run a simple FastAPI app?”
- “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.
| Option | Monthly cost signal from official docs | Complexity | Best fit | Fit for GHCR + Helm + Argo CD |
|---|---|---|---|---|
| Cloudflare Workers | Paid plan has a $5/month minimum, includes 10 million requests and 30 million CPU ms, with no additional Workers egress charges | Low to medium | Edge-first APIs and lightweight FastAPI apps that fit the Workers model | Low |
| Cloudflare Containers | Included in Workers Paid with 25 GiB-hours, 375 vCPU-minutes, and 200 GB-hours, then usage-based billing; container egress is billed separately by region | Medium | Container workloads on Cloudflare when you want a more container-like runtime than Workers | Low to medium |
| Render Web Service | Hobby workspace is $0/user/month plus compute; web services start at $0 and the first paid web service tier is $7/month | Low | Fastest developer experience for a small team shipping a Dockerized API | Low |
| Railway | Free 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 Pro | Low | Small teams that want strong DX, usage-based billing, Docker flexibility, and optional GHCR image deploys | Low to medium |
| AWS App Runner | Official examples show $4.80/month for a paused dev/test app and $25.50/month for a lightweight latency-sensitive API | Medium | Teams already on AWS that want managed containers without operating Kubernetes | Low |
| AWS EKS | $0.10/cluster-hour for standard support, which is roughly $73/month before worker nodes and other AWS resources | High | Teams that really want Kubernetes, Helm, Argo CD, and GitOps on AWS | High |
| Google Cloud Run | Request-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 service | Low to medium | Simple containerized APIs with scale-to-zero and low ops overhead | Low |
| Google GKE | $0.10/cluster-hour cluster management fee, which is roughly $74.40/month before node and networking costs | High | Teams that want Kubernetes GitOps on Google Cloud | High |
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 CIoption so deployments can wait for GitHub Actions to pass - health checks that wait for a
200response 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:
| Option | Type | Best fit | My take |
|---|---|---|---|
| Amazon EKS | Managed Kubernetes | AWS-first teams that want deep AWS integration | Strong choice when the rest of your platform is already on AWS |
| Google GKE | Managed Kubernetes | Teams on Google Cloud that want a polished managed Kubernetes experience | One of the cleanest managed Kubernetes experiences if GCP is already home base |
| Azure AKS | Managed Kubernetes | Azure-first teams and Microsoft-heavy organizations | A very reasonable default if your company already runs on Azure |
| DigitalOcean Kubernetes (DOKS) | Managed Kubernetes | Smaller teams that want simpler managed Kubernetes with fewer cloud primitives to learn | A good “I want Kubernetes, but not the whole hyperscaler tax” option |
| K3s on VPS or bare metal | Self-managed lightweight Kubernetes | Labs, edge, internal platforms, cost-sensitive teams, and teams that truly want full control | Excellent 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 --watchfor the local inner loop. - Keep optional local-only tools behind Compose profiles.
- Put chart defaults in
values.yaml, and keep environment overrides in explicitenvs/dev/values.yamlandenvs/prod/values.yamlfiles. - 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_TOKENfor same-repo package publication in Actions when possible.
Common mistakes
- Treating the registry as the source of truth instead of Git.
- Deploying
latest,main, ordevdirectly 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.
My recommended baseline
If you want one practical answer, it is this:
- Keep FastAPI local development on Docker Compose with
watch. - Push application images to
ghcr.io/<owner>/<repo>. - Push OCI Helm charts to
oci://ghcr.io/<owner>/charts. - Keep Argo CD
Applicationmanifests and environment values in a GitOps repo. - On merge to
main, auto-publish a dev image and chart, then auto-updatedev. - On
vX.Y.Ztags, auto-publish a release image and chart, then promoteprod.
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:
- GitHub Docs: Working with the Container registry
- GitHub Docs: Publishing Docker images
- GitHub Docs: Events that trigger workflows
- Docker Docs: Use Compose Watch
- Docker Docs: Using profiles with Compose
- Docker Docs: Control startup and shutdown order in Compose
- Docker Docs: Interpolation in Compose
- Helm Docs: Use OCI-based registries
- Argo CD Docs: Helm
- Argo CD Docs: OCI
- Argo CD Docs: Automated Sync Policy
- Argo CD Docs: Declarative setup for private Helm and OCI registries
- Argo CD Image Updater Docs: Update methods
- Argo CD Image Updater Docs: Update strategies
- Cloudflare Workers Docs: Pricing
- Cloudflare Workers Docs: FastAPI on Python Workers
- Render: Pricing
- Railway Docs: Pricing
- Railway Docs: Pricing Plans
- Railway Docs: Private Registries
- Railway Docs: Controlling GitHub Autodeploys
- Railway Docs: Healthchecks
- AWS App Runner Pricing
- Amazon EKS Pricing
- Amazon EKS: What is Amazon EKS?
- Google Cloud Run Pricing
- Google Kubernetes Engine documentation
- Azure Kubernetes Service: What is AKS?
- Azure Kubernetes Service pricing
- DigitalOcean Kubernetes Quickstart
- DigitalOcean Kubernetes: Managed Elements
- Google Kubernetes Engine Pricing
- K3s - Lightweight Kubernetes
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.