Skip to content

    Forward Deployed Engineer

    Ship the AI Service into a Customer's Environment

    Build the image, Kubernetes manifests, Terraform sketch and CI gate that give a customer's platform team a service they can run, secure and roll back.

    By Published 12 min read

    Share

    This lesson is part of Forward Deployed Engineer: Integrate and Ship. Enroll for free, then sign in, to read it in full.

    What you'll learn: how to hand a customer's platform team a service they can run: an image, Kubernetes manifests checked without a cluster, a Terraform sketch, secrets by reference, a CI gate and a rollback. No Docker daemon, cluster or Terraform was available here, and each step says what was and was not run. Facts come from the tools' documentation, as of 2026-11-04.

    At a glance

    Figure 1. What crosses the line into the customer's environment

    A repository feeds a CI pipeline that pushes an image to a registry; inside the customer's environment the platform runs the container, injects secrets from a secret store and probes the health endpointRepository to CI pipeline (push); CI pipeline to Image registry (image if green); Image registry to Service container (pull); Secret store to Service container (secrets); Health probe to Service container (GET health)CUSTOMER ENVIRONMENTRepository: code, no secretsRepositorycode, no secretsCI pipeline: tests firstCI pipelinetests firstImage registry: versioned imagesImage registryversioned imagesService container: reads env varsServicecontainerreads env varsSecret store: customer ownedSecret storecustomer ownedHealth probe: platform runs itHealth probeplatform runs itpushimage if greenpullsecretsGET health

    How to read this: everything left of the zone is yours and carries no secrets; secrets arrive from the customer's store when the container starts.

    • Hand over four things: an image, a settings table, a health endpoint and a runbook.
    • CI builds an image only after the tests, including the evaluation tests, pass.
    • Plan the rollback before the first release.

    Scenario

    Composite scenario, not a real company. Lumen Industrial Supply's platform team will run the product discovery copilot in its own Kubernetes cluster, which the FDE cannot reach. They ask what to run, what to configure, how to tell it is alive and what to do when a release goes wrong. The FDE answers with files they can review and a one-page runbook.

    What you will be able to do

    • Build and explain a non-root image that holds no secret.
    • Write Kubernetes manifests with probes, requests and a Secret reference, and check them offline.
    • Read a Terraform sketch and the CI job that validates it.
    • Describe a rolling update and a rollback.

    Prerequisites

    Core ideas

    The handover pack

    Item What you give them Capstone fact
    Image One image per release, with a version tag deploy/Dockerfile
    Settings Every setting, its default, whether it is secret Settings.from_env() reads environment variables; deploy/k8s/configmap.yaml holds non-secret ones
    Health endpoint A URL that answers 200 when the process is up GET /api/v1/health returns status: ok and the provider name
    Runbook One page: symptoms, first checks, rollback, who to call You write it

    The capstone's defaults suit a laptop: ALLOW_DEV_TOKENS is true and DEV_SECRET has a built-in value. The image and ConfigMap turn tokens off, and the Deployment takes DEV_SECRET from a Secret. The health endpoint does not call the model provider, so a provider outage cannot make the platform restart every copy.

    The container image

    Real file, deploy/Dockerfile, lines 3 to 26 (not built here: no Docker daemon):

    FROM python:3.12-slim
    
    WORKDIR /srv/copilot
    
    # Dependencies first: this layer is reused until requirements.txt changes.
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    
    # The service reads app/ and data/ only. No .env file, no key, no token secret.
    COPY app/ app/
    COPY data/ data/
    
    # Unprivileged user: a fixed numeric id, the same one runAsUser sets in the Kubernetes manifest.
    USER 10001
    
    # Safe defaults only. DEV_SECRET and ANTHROPIC_API_KEY arrive when the container starts.
    ENV LLM_PROVIDER=mock ALLOW_DEV_TOKENS=false PYTHONUNBUFFERED=1
    EXPOSE 8000
    
    # Exit status 0 is healthy, 1 is unhealthy. The route does not call the model provider.
    HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
      CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health', timeout=2)"]
    
    CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
    Line Why Source
    COPY requirements.txt first "If a layer changes, all other layers that come after it are also affected" cache
    FROM python:3.12-slim CI tests 3.12; a tag is mutable, a digest pins it practices
    USER 10001 Run without privileges; an explicit ID is deterministic practices
    HEALTHCHECK Exit 0 is healthy, 1 unhealthy; only the last one counts reference
    EXPOSE, exec-form CMD EXPOSE documents the port, it does not publish it; exec form suits services reference

    deploy/Dockerfile.dockerignore keeps .venv, .env and tests out of the build context (context). To build and run (not run here):

    cd capstone/fde-product-discovery-copilot
    docker build -f deploy/Dockerfile -t registry.example/lumen/copilot:0.1.0 .
    export DEV_SECRET="<a long random value from your secret store>"
    docker run --rm -p 8000:8000 --env DEV_SECRET registry.example/lumen/copilot:0.1.0

    --env VAR with no value passes the variable from your shell (docker run), so the secret is in no command or file.

    The Docker daemon was not running, so docker build and docker run were not run. Instead the image's contents (app/, data/) and start command ran from a temporary folder with the environment the image sets, on Python 3.14 (the .venv), not the image's 3.12:

    health probe exit=0
    {"status":"ok","provider":"mock"}
    dev token status=404
    search status=200
    closed port probe exit=1

    Lines: probe exit status, health body, token endpoint (off), a catalogue search, the probe against a closed port. It does not show that the image builds or that user 10001 can read data/.

    Kubernetes manifests, checked without a cluster

    deploy/k8s/ holds a ConfigMap, a Deployment and a Service. There is no Secret file: it would hold the value. The Deployment points at a Secret that the customer's store creates.

    Real file, deploy/k8s/deployment.yaml, lines 36 to 70:

                - name: DEV_SECRET
                  valueFrom:
                    secretKeyRef:
                      name: copilot-secrets
                      key: dev-secret
                - name: ANTHROPIC_API_KEY
                  valueFrom:
                    secretKeyRef:
                      name: copilot-secrets
                      key: anthropic-api-key
                      optional: true
              readinessProbe:
                httpGet:
                  path: /api/v1/health
                  port: http
                periodSeconds: 10
                timeoutSeconds: 2
                failureThreshold: 3
              livenessProbe:
                httpGet:
                  path: /api/v1/health
                  port: http
                initialDelaySeconds: 20
                periodSeconds: 20
                timeoutSeconds: 2
                failureThreshold: 3
              resources:
                requests:
                  cpu: 100m
                  memory: 256Mi
                limits:
                  memory: 512Mi
              securityContext:
                runAsNonRoot: true
                runAsUser: 10001
    Field What it does Kubernetes documentation
    secretKeyRef, optional Fills a variable from one Secret key; optional says whether it must exist Secrets, Pod API
    readinessProbe A failing Pod "will not receive traffic from any services" probes
    livenessProbe A failure makes the kubelet kill and restart the container probes
    resources.requests The scheduler places the Pod by them; the kubelet enforces limits resources
    runAsNonRoot The kubelet refuses a container that would run as UID 0 Pod API
    replicas: 1, maxUnavailable: 0 One copy, as the idempotency store is in memory; no downtime in an update Deployments

    A ConfigMap gives no secrecy or encryption (docs), so it holds non-secret settings only.

    Check it with no cluster. The usual client-side check is kubectl apply --dry-run=client --validate=false -f deploy/k8s/. With a kubeconfig pointing at a closed port, it still asked the API server which kinds exist and failed (first error only):

    unable to recognize "deploy\\k8s\\configmap.yaml": Get "http://127.0.0.1:9/api?timeout=32s": dial tcp 127.0.0.1:9: connectex: No connection could be made because the target machine actively refused it.

    kubectl annotate --local parses files without contacting a server (reference) and prints each object as JSON. deploy/check_manifests.py reads that JSON and checks what a schema does not: selector equals Pod labels, a version tag, both probes, requests, non-root, no literal secret, no Secret object.

    kubectl annotate --local -f deploy/k8s/ checked-by=ci -o json | python deploy/check_manifests.py

    Real output on the committed files, then on a copy with :latest and one Pod label changed:

    manifest check: ok
    
    PROBLEM: copilot: selector does not match the pod template labels
    PROBLEM: copilot/copilot: image needs a version tag, not latest
    PROBLEM: copilot: no Deployment matches the Service selector
    manifest check: failed

    Limit, found by trying: a misspelled field (containerPortt) still passed. Left to you: kubectl apply --dry-run=server against the customer's cluster.

    Terraform: a sketch for the platform team

    deploy/terraform/main.tf expresses the namespace and the Deployment with the Kubernetes provider, for teams that manage clusters as code. Manage one object with manifests or Terraform, never both.

    Sketch, to be validated by CI, not run here (lines 64 to 88 of deploy/terraform/main.tf):

              port {
                name           = "http"
                container_port = 8000
              }
    
              # A reference to a Secret that the customer's secret store creates; no value is written here.
              env {
                name = "DEV_SECRET"
                value_from {
                  secret_key_ref {
                    name = "copilot-secrets"
                    key  = "dev-secret"
                  }
                }
              }
    
              readiness_probe {
                http_get {
                  path = "/api/v1/health"
                  port = "http"
                }
                period_seconds    = 10
                timeout_seconds   = 2
                failure_threshold = 3
              }

    Block names follow the provider's Deployment resource. The provider block holds no credentials; its page says to supply a kubeconfig path, for example KUBE_CONFIG_PATH.

    Terraform was not installed here, so fmt, init and validate have not run on this file. The new workflow .github/workflows/fde-deploy-validate.yml runs them, but has not run yet: treat the sketch as unproven until it is green.

    Secrets: where they live instead

    Place Verdict Why
    Baked into the image (ENV, build argument) Never Docker: these "persist in the final image"
    Committed to the repository Never Anyone with the repository and its history has it
    CI secret store CI only This repository's tests need none
    Customer's secret store, injected at start Default The image stays identical in every environment
    Kubernetes Secret Acceptable, with care Stored unencrypted in etcd by default; encrypt at rest, restrict access

    The customer's store creates the Secret the manifest names. By hand (not run here: no cluster):

    kubectl create secret generic copilot-secrets --namespace lumen-copilot --from-file=dev-secret=./dev-secret.txt

    A build that needs a secret uses a secret mount, available only while the instruction that mounts it runs (build secrets). The capstone needs none.

    Spot the flaw. A reviewer rejects this Dockerfile fragment. Name the defect.

    FROM python:3.12
    ARG ANTHROPIC_API_KEY
    ENV ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY
    COPY . .
    Answer

    The key is passed as a build argument and stored with ENV, so it is saved in the image; anyone who can pull the image can read it. Supply the key when the container starts, from the customer's secret store.

    The CI pipeline: what it gates

    First, when the repository's workflow .github/workflows/fde-capstone.yml runs.

    Real file, lines 3 to 18:

    # Runs the Forward Deployed Engineer capstone's Python tests and the lesson code that the course pages quote.
    # pull_request (never pull_request_target): fork PRs run with a read-only token and no secrets. No API key is needed:
    # the tests use the deterministic mock provider and never touch the network.
    on:
      pull_request:
        paths:
          - "capstone/**"
          - "content/learning/fde-*.mdx"
          - "shared/fdeRules.mjs"
          - "shared/fdeManifest.mjs"
          - ".github/workflows/fde-capstone.yml"
      push:
        branches: [main]
        paths:
          - "capstone/**"
          - "content/learning/fde-*.mdx"

    GitHub says a workflow skipped by a paths filter leaves its check "Pending", which can block a required merge.

    Real file, lines 20 to 41:

    permissions:
      contents: read
    
    jobs:
      pytest:
        name: pytest (capstone and lesson code)
        runs-on: ubuntu-latest
        timeout-minutes: 15
        defaults:
          run:
            working-directory: capstone/fde-product-discovery-copilot
        steps:
          - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
            with:
              persist-credentials: false
          - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
            with:
              python-version: "3.12"
              cache: pip
              cache-dependency-path: capstone/fde-product-discovery-copilot/requirements.txt
          - run: pip install -r requirements.txt
          - run: python -m pytest -q
    Line or step What it does
    permissions: contents: read The token can only read; GitHub advises the minimum
    uses: ...@34e114... Pinned to a full commit hash; GitHub says a tag can be moved
    python -m pytest -q Runs tests and lessons (set in pytest.ini) on Python 3.12, the Dockerfile's version

    There is no separate evaluation step and no image build; the evaluation is inside pytest: test_observability_and_eval.py runs the 36-question golden set with the mock provider and fails if strategy A or B leaks partner-only content or an answer is not grounded. That gates the code and the access rules, not a real model's quality. To add an image build, add a job that needs the test job.

    Sketch, not in this repository, never run:

      image:
        needs: pytest
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@<full commit hash>
          - run: docker build -f deploy/Dockerfile -t registry.example/discovery:<version> .
            working-directory: capstone/fde-product-discovery-copilot

    GitHub says that when a needed job fails or is skipped, the jobs that need it are skipped unless they use a conditional such as always(). So a red test means no image. Do not add always() to the image job.

    The deploy files have their own workflow, .github/workflows/fde-deploy-validate.yml. Its Terraform job (lines 35 to 37):

          - run: terraform fmt -check
          - run: terraform init -backend=false
          - run: terraform validate

    Terraform says validate checks that a configuration is syntactically valid and internally consistent, without remote services, after init -backend=false (validate). The second job runs the manifest check above with shell: bash, which GitHub runs with -eo pipefail, so a failing kubectl fails the pipe. That workflow has not run yet.

    Figure 2. The delivery pipeline with its gates

    flowchart LR accTitle: Delivery pipeline with a test gate and a rollback path accDescr: A pull request runs the tests including the evaluation tests and the deploy file checks. If they fail, no image is built. If they pass, an image is built and pushed to the registry, the customer's platform rolls it out, and a failed health check sends the release back to the previous image. A["Pull request"] --> B["Tests, evaluation tests, deploy checks"] B -->|"red"| X["Stop: no image"] B -->|"green"| C["Build image"] C --> D["Push to registry"] D --> E["Platform rolls out"] E --> F{"Health ok?"} F -->|"yes"| G["Keep release"] F -->|"no"| H["Roll back to previous image"]

    How to read this: the first gate is yours (checks), the second is the platform's (health); each has a stop or a way back. The repository's workflows stop after the checks; the image steps are the sketch.

    Networking and data residency: ask early

    Question What it decides
    May prompts and documents leave the network, and to which regions? Whether a hosted model is allowed
    Does the model call use the internet or a private connection? The egress rule
    Which logs may hold prompt text? What the service logs
    Who can read the secret store and registry? Who can see the key

    Who owns what

    Area Customer's platform team usually owns You own
    Cluster Kubernetes: scheduling, rollouts, Secrets Image, settings table, manifests as a proposal
    Infrastructure Infrastructure as code: networks, registries, access What the service needs, the Terraform sketch
    Delivery Their CI/CD, approvals, change windows Tests and gate in your repository
    Operations On-call, alerts, backups Runbook and emitted signals

    The ideas underneath are in reliability basics, resilience patterns, observability and SLOs, load balancing and security.

    Rollout and rollback

    • A rolling update scales a new set of Pods up while the old set scales down, by default keeping at least 75 percent of the desired Pods up and allowing at most 125 percent.
    • maxUnavailable rounds down and maxSurge rounds up, so with one copy the defaults already mean 0 and 1; the manifest says so.
    • kubectl rollout undo deployment/copilot --namespace lumen-copilot returns to the previous revision. It restores only the Pod template, so a change made elsewhere is not undone.

    At the customer: a platform engineer will ask, "How do we roll this back, and what state does it leave?" A good answer names the command, says the previous image stays in the registry, and says what state the service keeps: the capstone's idempotency store is in memory (app/idempotency.py).

    Worked example

    Assumptions: illustrative; the manifest above, one copy, release 2 changes only the image tag. Not run: no cluster.

    1. Tests and deploy checks are green, image 0.2.0 is pushed and the manifest's tag changes.
    2. The update starts one new Pod beside the old one (maxSurge: 1); the old one stays (maxUnavailable: 0).
    3. The new Pod fails its readiness probe: a setting is missing. It gets no traffic; users still reach the old Pod.
    4. kubectl rollout status shows the rollout unfinished. On-call saves the new Pod's logs (kubectl logs), because the rollback removes that Pod, then runs kubectl rollout undo; the FDE reads the saved logs.

    Save the evidence, roll back, then diagnose.

    Decision exercise. An hour after a release, the customer finds ALLOW_DEV_TOKENS was left at its default. Options: (a) correct the setting in place; (b) roll back, then redeploy with it corrected; (c) wait for the next release. Choose and justify.

    Model answer

    Fix it now, through the platform's change process; (c) leaves a token endpoint open. Choose (a) when only the setting is wrong, because a rollback would also undo the rest of the release.

    Practice task

    Write the Lumen handover as one page.

    1. Reproduce the gate and the deploy check:
    cd capstone/fde-product-discovery-copilot
    python -m pytest -q
    kubectl annotate --local -f deploy/k8s/ checked-by=ci -o json | python deploy/check_manifests.py
    1. Break selector.matchLabels in a copy of deployment.yaml; run the check and read the message.
    2. Tabulate the settings in app/config.py: name, default, secret or not, production value.
    3. Add the health URL, what it does not check, and three runbook lines: first check, rollback, who to call.

    Decide first.

    Ship it to Lumen (interactive when JavaScript is on)

    Lumen's platform team asks where the model provider key should live.

    • Set with ENV in the Dockerfile
    • Injected from their secret store at start
    • In a .env file committed to the repository
    Answer

    Injected from their secret store at start. Docker says environment variables persist in the final image, so the key must arrive at start. ENV puts it in every copy.

    A rolling update stalls: one new copy fails its readiness probe.

    • Wait; it will pass eventually
    • Lower the probe's threshold until it passes
    • Save the new copy's logs, run the rollback command, then diagnose
    Answer

    Save the new copy's logs, run the rollback command, then diagnose. A not-ready copy gets no traffic; rolling back restores a known good revision and removes the failed copy, so save its logs first. Loosening the probe hides the problem it caught.

    A fork pull request fails because a repository secret is not available to it.

    • The tests should not need a key: they use the mock provider
    • Switch the trigger to pull_request_target to pass the secret
    • Commit the key to the fork's branch
    Answer

    The tests should not need a key: they use the mock provider. GitHub does not pass secrets to fork-triggered workflows, and the tests use the mock provider. pull_request_target would expose secrets to outside code.

    Exercise

    Write the image job from the sketch in a scratch file, not the repository's workflow. It must need the test job, hold no secret and use a version tag.

    Done when a reviewer can say, for a red test, which jobs run.

    Hint 1: What stops the image job when tests fail?

    One keyword on the job; GitHub's documentation on job dependencies names it.

    Hint 2: Where does approval go?

    The keyword takes the job's id (pytest here), not its display name.

    Common mistakes

    • Putting a secret in ENV, a build argument or a Secret manifest in the repository.
    • Treating --dry-run=client as offline, or "tests pass" as "model quality is fine".
    • Adding always() to the image job.
    • Leaving the rollback command out of the runbook.

    Knowledge check

    1. Why does the image carry no secrets?

    Docker says build arguments and environment variables persist in the final image. Anyone who can pull the image could read the key.

    2. The test job fails. What happens to a job that lists it under `needs`?

    It is skipped. GitHub says jobs that need a failed or skipped job are skipped unless they use a conditional such as always(), so a red test blocks the image.

    3. What does the CI gate prove, and what does it not?

    It proves the 36-question evaluation shows no access leaks in strategies A and B, and grounded answers. It cannot measure a real model: the mock gives the same answers every run.

    4. Why does the health endpoint not call the model provider?

    A failed liveness probe restarts the container. A provider outage would then restart every copy for a fault restarting cannot fix.

    5. The manifest check passes. What can still be wrong?

    A misspelled field name, or anything the cluster's schema or admission rules reject: the check parses files and applies a few rules without asking a cluster. Run a server-side dry run on the customer's cluster.

    You can now

    • Explain why secrets arrive at start, not in the image or a manifest.
    • Read the Dockerfile, the manifests and fde-capstone.yml and say what each gates.
    • Describe a rolling update and a rollback.

    Further reading

    Last verified: 2026-11-04

    Next

    This is an optional page. Return to the core path: The Integration Design Note, and When Java Wins

    Enroll to keep reading

    Lesson 5 of 5 · Forward Deployed Engineer: Integrate and Ship

    Build the image, Kubernetes manifests, Terraform sketch and CI gate that give a customer's platform team a service they can run, secure and roll back.

    In the rest of this lesson

    • Worked example
    • Practice task
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Integrate and Ship, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments