Skip to content
RunDeploy

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.

  • The quick start’s workspace: order-extractor, order-loader, Contracts, and the order-flow host, 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 the dapr CLI
Terminal window
cd intropy-dev-env
./up.sh
./status.sh

up.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:

order-extractor/src/Composition/Composition.cs
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.

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):

Terminal window
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:dev

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:

Terminal window
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:

Terminal window
kubectl -n integration get pods

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:

Terminal window
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=true
cat > /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 }
]
}
EOF
rclone copy /tmp/sample-order.json garage:dev-fixtures/sftp/order-extractor-source/in/'

The next CronJob run sweeps the file. Its logs tell the whole story:

Terminal window
kubectl -n integration logs job/$(kubectl -n integration get jobs \
--sort-by=.metadata.creationTimestamp -o name | tail -1 | cut -d/ -f2) -c order-extractor
info: OrderExtractor.Pipeline[0]
Swept 1 file(s) from inbound
info: 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 cancelled

RabbitMQ 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.

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.