First deploy
This walkthrough takes the quick start’s order-flow System and runs it the way production runs it: rendered Kubernetes manifests, a real RabbitMQ broker, real SFTP endpoints, and the real idempotency and business-incident services — no Aspire, no mocks. At the end an order file dropped on an SFTP server comes out transformed on another, having crossed a message broker between two independently deployed Components.
The Aspire dev loop answers “does my System work?”; this answers “does it work deployed?” — with real workload kinds (the extractor becomes a CronJob, the loader a Deployment), environment-owned port bindings, and platform services that persist state. In Build, connect, deploy terms, this walkthrough is the connect step performed: the same image, pointed at real endpoint types.
What you need
Section titled “What you need”- The quick start’s workspace:
order-extractor,order-loader,Contracts, and theorder-flowhost, scaffolded and committed - The intropy-dev-env repository, cloned with the two service repos as siblings (
idempotency-service,business-incident-service) - Docker (running), plus
k3d,kubectl,helm, and thedaprCLI
1. Stand up the environment
Section titled “1. Stand up the environment”cd intropy-dev-env./up.sh./status.shup.sh creates a disposable single-node k3d cluster named intropy and installs, in order: the Dapr control plane, RabbitMQ, a Garage S3 store with an SFTPGo front (the fixtures namespace), and the two platform services with their databases (the services namespace). An empty integration namespace is the landing zone your workloads deploy into. Everything is ephemeral: ./down.sh deletes the cluster and every byte of state.
./status.sh should show every pod Running or Completed before you continue.
2. Select the file adapter per environment
Section titled “2. Select the file adapter per environment”This environment’s file fixtures are SFTP endpoints (served by SFTPGo). The scaffolds compose LocalFileAdapter, which speaks the local-storage binding’s response format — the right adapter for the Aspire dev loop, but against an SFTP binding its file listing fails. Which adapter a port resolves to is environment-owned, so the adapter choice branches on the environment, not the code path.
The scaffold already reads the environment at the top of ConfigureServices (the local run sets DOTNET_ENVIRONMENT=Development; deployed manifests set nothing, so it defaults to Production). Reuse it to pick the adapter:
services.AddKeyedSingleton<IFileAdapter>(Constants.SourceFileAdapterKey, (sp, _) => environment == "Development" ? (IFileAdapter)new LocalFileAdapter( sp.GetRequiredService<DaprClient>(), new FileAdapterOptions(Constants.InboundBindingName)) : new SftpAdapter( sp.GetRequiredService<DaprClient>(), new FileAdapterOptions(Constants.InboundBindingName)));Make the same change in order-loader/src/Composition/Composition.cs for the destination adapter. The SftpAdapter branch is this walkthrough’s choice because this environment serves files over SFTP; an integration whose real endpoint is blob storage would put its blob adapter there instead. The development loop keeps working unchanged either way.
3. Build and import the images
Section titled “3. Build and import the images”Each component’s scaffold carries a Dockerfile; the shared contracts project enters as a named build context. The rendered manifests reference images as local/<component>:dev, so tag them exactly that and side-load them into the cluster (nothing gets pushed to a registry):
cd order-extractor && docker build --build-context contracts=../Contracts -t local/order-extractor:dev . && cd ..cd order-loader && docker build --build-context contracts=../Contracts -t local/order-loader:dev . && cd ..
k3d image import -c intropy local/order-extractor:dev local/order-loader:dev4. Render and apply the manifests
Section titled “4. Render and apply the manifests”From the workspace root, render the System’s local-environment manifests and pipe them to the cluster. Every port needs a fixture binding; this environment serves files over SFTP, so bind both ports to the sftp fixture:
intropy manifests render --env local --namespace integration \ --binding order-extractor-source=sftp \ --binding order-loader-destination=sftp | kubectl apply -f -What lands in the integration namespace:
| Resource | Kind | Why |
|---|---|---|
order-extractor |
CronJob (* * * * *) |
Run-to-completion Component; the schedule is deployment configuration, not topology |
order-loader |
Deployment | Long-running subscriber |
order-extractor-source, order-loader-destination |
Dapr Component (bindings.sftp) |
The ports, resolved to SFTPGo with this environment’s address and credentials |
pubsub |
Dapr Component | RabbitMQ in the fixtures namespace |
Within a minute the CronJob fires and completes (a sweep of an empty folder is a successful no-op), and the loader sits subscribed:
kubectl -n integration get pods5. Seed an order
Section titled “5. Seed an order”The fixtures share one Garage bucket: an SFTP path /order-extractor-source/in/x is the object sftp/order-extractor-source/in/x in the dev-fixtures bucket. Seeding is therefore one rclone copy from a transient pod, using the environment’s pinned dev credentials:
kubectl -n fixtures run seed --rm -i --restart=Never --image=rclone/rclone:1.70.3 --command -- sh -c 'export RCLONE_CONFIG_GARAGE_TYPE=s3 RCLONE_CONFIG_GARAGE_PROVIDER=Other \ RCLONE_CONFIG_GARAGE_ACCESS_KEY_ID=GKdev0fixture0000000001 \ RCLONE_CONFIG_GARAGE_SECRET_ACCESS_KEY=dev-fixture-secret-not-real-0001 \ RCLONE_CONFIG_GARAGE_ENDPOINT=http://garage.fixtures.svc.cluster.local:3900 \ RCLONE_CONFIG_GARAGE_REGION=garage RCLONE_CONFIG_GARAGE_FORCE_PATH_STYLE=truecat > /tmp/sample-order.json <<EOF{ "orderId": "ORD-1001", "customerId": "CUST-42", "orderDate": "2026-05-13T09:30:00Z", "lines": [ { "sku": "SKU-AAA", "quantity": 2, "unitPrice": 19.95 }, { "sku": "SKU-BBB", "quantity": 1, "unitPrice": 49.00 } ]}EOFrclone copy /tmp/sample-order.json garage:dev-fixtures/sftp/order-extractor-source/in/'6. Watch it cross
Section titled “6. Watch it cross”The next CronJob run sweeps the file. Its logs tell the whole story:
kubectl -n integration logs job/$(kubectl -n integration get jobs \ --sort-by=.metadata.creationTimestamp -o name | tail -1 | cut -d/ -f2) -c order-extractorinfo: OrderExtractor.Pipeline[0] Swept 1 file(s) from inboundinfo: OrderExtractor.Pipeline[0] Published ORD-1001 to topic orders (pubsub)info: Intropy.Framework.Hosting.RunToCompletion.RunToCompletionRunner[0] Job order-extractor completed: 1 processed, 0 failed, 0 cancelledRabbitMQ delivers the message to the loader, which writes the transformed order through its own SFTP port. Read it back with the same seed contract (rclone cat garage:dev-fixtures/sftp/order-loader-destination/in/ORD-1001.json):
{"OrderId":"ORD-1001","CustomerId":"CUST-42","LoadedAt":"2026-08-13T04:52:01.7452341+00:00","Status":"Loaded"}The source object is gone — swept and deleted, the run-to-completion contract. Along the way both Components called the real platform services over Dapr service invocation: each committed an idempotency record, and a validation failure would land in the business-incident service’s database rather than a mock’s void.
Where this leads
Section titled “Where this leads”Everything here was still manual: locally built images, kubectl apply, a cluster that ./down.sh erases. The rest of this section replaces the manual steps with the durable path — a GitOps repository that owns the manifests, releases that name immutable images, and promotion that moves them through real environments with ArgoCD reconciling. The mechanics you just watched (workload kinds, ports as environment-owned bindings, platform services over service invocation) are identical there; only the delivery becomes declarative.