Two shapes of work

Azure Container Apps gives you two top-level resource types. They take the same container image, sit in the same environment, share secrets and identity and networking - and yet they model fundamentally different workloads. The difference is not the image. It is the contract the platform expects the container to honour.

Same image. Different runtime contract.
CONTAINER APP

A service

Stays up. Listens on a port. Answers requests or polls work in its own loop. The platform monitors it for liveness and keeps it running.

Exiting is a failure. The platform restarts it, and if it keeps exiting, marks the revision unhealthy.

Definition of healthy: still running.
CONTAINER APP JOB

A task

Starts. Does work. Exits. The platform schedules it - by cron, by queue depth, or on demand - and waits for it to finish.

Exiting cleanly is success. A non-zero exit code or a timeout triggers a retry, up to the configured limit.

Definition of healthy: exited 0.

That is the whole framing in one image. Everything below is a consequence of it.

Exit is the dividing line

The clearest test, when you are not sure which one to reach for, is to ask what should happen when the container's main process exits.

If the answer is "the platform should restart it" - because exiting means something went wrong, the process should have stayed up - what you want is a Container App. If the answer is "the platform should mark that execution done" - because the work has finished, the exit code is the result - what you want is a Container App Job.

This catches a category of mistake in advance. A long-running worker that polls a queue in a while true loop is a service: it should never exit, exiting means a bug. The same logical work modelled as "wake up, drain a batch, exit" is a task: it should always exit, not exiting means a hang. The image can be identical; the contract is not.

Ingress vs trigger

Apps and jobs answer different questions about how work arrives at the container.

A Container App declares ingress. The container says "I listen on port 8080" and the platform puts an HTTPS endpoint in front of it, terminates TLS, and forwards traffic. Traffic is the input. Whether anyone is calling you is determined by clients, not by configuration.

A Container App Job declares a trigger. The job says "start me on this cron schedule" or "start me when this queue has messages" or "start me when I ask you to from a pipeline". The trigger is configuration, not traffic. The platform is the one deciding when an execution begins.

That is why an app has no schedule and a job has no ingress. Asking a job to listen on a port, or asking an app to run nightly at 2 AM, is asking the wrong resource the wrong question.

How they scale

Both apps and jobs use KEDA under the hood, and both can scale to zero. But what they scale is different, and the difference shows up in the bill.

A Container App scales the number of replicas of a continuously running service. A replica goes up because requests are queueing or a metric crossed a threshold; it stays up as long as the load is there; it goes down when the load fades. Each replica is a long-lived process.

A Container App Job scales the number of executions. Each execution is one bounded run of the container, with its own start, work, and exit. The scaler does not keep replicas alive; it spawns new ones when there is work and lets them finish. Inside one execution you can also set parallelism to run several replicas at once, but each of those still exits when its share of the work is done.

The practical effect: an idle app that has scaled to zero will sit at zero until a request arrives at the ingress. An idle event-driven job will sit at zero until the scaler sees pending work in the queue.

Revisions vs executions

Apps and jobs both produce immutable history when you deploy, but they call it different things and use it for different reasons.

A Container App produces revisions. A revision is a frozen template, and the platform can keep several alive at once, splitting traffic between them by percentage. Revisions are how you do blue-green and canary releases: ship the new revision at 0%, smoke-test it on its own URL, shift 10%, watch the metrics, then take it to 100%.

A Container App Job produces executions. An execution is one run of the job - one trigger fire, one finite chunk of work. Executions are how you do audit: every nightly report, every queue batch, every manual run leaves an execution record with logs, exit code, and duration. There is no "100% of traffic on this execution"; there is just "this one ran, here is what happened".

Same idea - an immutable receipt of something that happened - applied to two very different lifecycles.

What you actually pay for

Both resources bill on the same currency: vCPU-seconds and GiB-seconds of running replica time, plus the same monthly free grant. But the runtime profile of each one makes the bill look very different in practice.

Axis
Container App
Container App Job
When billing accrues
Continuously, for every second a replica is alive.
Only while an execution's replicas are running, start to exit.
Idle cost
Zero only if minReplicas is 0 and no traffic is arriving.
Zero by construction - no work, no replicas, no charge.
Cold-start exposure
Affects user-facing latency. Often worth keeping one replica warm.
Affects total execution time but rarely user-facing. Cold start is part of the cost.
Best when
Work arrives steadily or sub-second; replicas earn their keep by staying up.
Work is bursty, periodic, or sub-hourly; spinning up per execution is cheaper than staying alive.

The pattern that catches people out: a worker service that wakes up once an hour, processes for 90 seconds, then idles. As a Container App with minReplicas: 1 it bills for 3,600 seconds an hour. As a Container App Job it bills for 90 seconds an hour - the other 99% of the time, there is nothing running to charge for.

A four-question decision tree

If you walk through these in order, you almost never end up on the wrong side.

1. Does the work finish?
No · App

If the workload is meant to run forever - serving traffic, holding a connection, polling in its own loop - it is a service. Use a Container App.

Yes · continue

If the workload has a clear end - a report is written, a queue batch is drained, a migration completes - keep going.

2. Who decides when it starts?
A client · App

If a request, a connection, or another service is what causes the work to begin, the input is traffic. Put it behind ingress as a Container App.

A schedule, a queue, or a pipeline · Job

If a cron expression, a KEDA scaler, or a pipeline kicks it off, the input is a trigger. Run it as a Container App Job.

3. Should exiting be a success or a failure?
Failure · App

If the container exiting means something is wrong and you want the platform to restart it, the contract is a service. Container App.

Success · Job

If exiting cleanly means the work is done and the platform should mark the execution complete, the contract is a task. Container App Job.

4. How often does it actually run?
Constantly · App

Steady traffic or near-continuous work justifies a long-lived replica. The bill scales with load, not with idleness.

In bursts or rarely · Job

Periodic, bursty, or sparse work pays for what it uses. Scale-to-zero is free; idle replicas are not.

If all four answers point the same way, the decision is easy. If they conflict, question 3 is the tiebreaker - what should "exited" mean? That answer is almost always the right model.

When you need both

The pairing is the point. Most real applications have at least one of each, and putting them in the same Container Apps environment is the whole reason this design exists.

A typical stack: an API as a Container App, talking to a database; a queue worker as a Container App Job, scaled by Service Bus depth; a nightly maintenance task as a Container App Job on a cron schedule; an admin tool as another Container App behind internal-only ingress. All four live in the same environment, share secrets and managed identity, log to the same place, and follow the same deployment story.

Notice what is not in that list: a single resource that "sort of does both". When you find yourself wanting one, the answer is almost always to split it into an App and a Job that talk to each other through a queue or a database. The split is the design, not a workaround.

If your stack is already modelled in Aspire, this is even smoother - the orchestrator deploys services and jobs to the same environment from the same code, so you don't write the manifests by hand.

RULE

Exit tells you which

If exiting is a failure, it's an App. If exiting is the result, it's a Job. That single test resolves most of the ambiguity.

INPUT

Ingress or trigger

Apps take traffic. Jobs take triggers. Schedules and queue scalers belong on jobs; HTTPS endpoints belong on apps.

BILL

Match it to the work

Steady load pays for staying up. Bursty load pays for showing up - and saves the rest of the time at zero.

PAIR

Run them side by side

One environment, both resources, shared identity and secrets. The pairing is the design, not a workaround.