Skip to main content
If you have been serving models on Baseten with Truss, this guide maps Baseten concepts to their fal equivalents and shows how to convert your code. The core idea is similar: both platforms wrap a Python class with a startup hook and a request handler, then scale it behind a managed HTTP API. The main differences are that fal keeps configuration in the Python class rather than a separate config.yaml, and that a fal.App serves as many named endpoints as you declare rather than a single predict route. For a broader overview of deploying existing Docker containers on fal (regardless of where they came from), see Deploy an Existing Server. If you are comparing fal to other platforms, see Migrate from Replicate, Migrate from Modal, or Migrate from RunPod.

Concept Mapping


Choosing a Path

Pick the path that matches how your Truss serves requests:

Migration Checklist

The steps below are the full sequence for Path 1. Each one is expanded later in the guide.
  1. Create one Python file next to your Truss. Everything in config.yaml and model/model.py ends up here.
  2. Convert the class. class Model: becomes class MyApp(fal.App), load(self) becomes setup(self), and predict(self, model_input) becomes a method decorated with @fal.endpoint("/"). Drop __init__ unless it does work beyond capturing kwargs.
  3. Move config.yaml keys to class attributes using the Concept Mapping table. requirements carries over verbatim. resources.accelerator becomes machine_type via the GPU Mapping table.
  4. Type the interface. Replace the untyped model_input dict with a Pydantic Input model, and return a Pydantic Output model. Replace base64-encoded files with fal.toolkit types such as Image or File.
  5. Handle python_version. If your Truss pinned it and your dependency pins depend on it, add a pyproject.toml. See Python Version.
  6. Recreate secrets. For each key under secrets: in config.yaml, run fal secrets set NAME=value, and replace self._secrets["name"] with os.getenv("NAME").
  7. Delete the cache configuration. model_cache and external_data have no equivalent and are not needed. See Model Weights and Caching.
  8. Validate with fal run <file>::<Class> before deploying. This runs setup() on a real runner. If you added a pyproject.toml in step 5, the argument is the app key instead: fal run <app-key>.
  9. Deploy with fal deploy <file>::<Class>, or fal deploy <app-key> when using pyproject.toml.
  10. Repoint callers from https://model-{MODEL_ID}.api.baseten.co/... to fal_client.subscribe("your-username/your-app"). See Calling Your App.
  11. Re-baseline output tests. Seeded outputs shift when the GPU changes. See the note under GPU Mapping.

Migration Path 1: A Truss Model

This is the path almost every migration takes. A Truss is a directory containing config.yaml and model/model.py, and it collapses into a single fal application file. The example below is an SDXL-Turbo text-to-image model. The first tab is the Truss as you would push it to Baseten, and the second is the same model as a fal.App.
config.yaml:
model/model.py:
Key differences in the fal version:
  • config.yaml disappeared. resources.accelerator became machine_type and requirements became a class attribute. python_version is the one field with no class-attribute equivalent, and it is the first thing most Truss migrations trip over. See Python Version.
  • load became setup. Same contract: it runs once per runner, and whatever you hang off self survives for that runner’s lifetime.
  • predict became an endpoint. @fal.endpoint("/") mounts the handler at the app root, which is what fal_client calls by default.
  • The untyped model_input dict became a Pydantic model. Baseten hands you whatever JSON arrived. fal validates against Input first and rejects malformed requests before your code runs. The same models generate your app’s OpenAPI spec, which powers the Playground and the generated client code.
  • Base64 became a hosted file. fal.toolkit.Image.from_pil() uploads the image to fal’s CDN and returns a URL in the response, so large outputs never travel through your JSON payload. See Working with Files.
  • predict_concurrency: 1 is the fal default. A runner handles one request at a time unless you raise max_multiplexing.
  • The declared secret went away. hf_access_token was never read by this model. If yours does read one, see Secrets below.
Note that torch_dtype=torch.float16 and .to("cuda") are unchanged. The inference code itself almost never needs edits. A migration changes the wrapper around the model, not the model.

Deploying

Before deploying, validate the app the way you would iterate against a Baseten development deployment with truss push --watch:
fal run boots your app on a temporary runner, executing setup() and your endpoints exactly as production will. Import errors, missing dependencies, and model-loading failures surface here instead of as a production crashloop. It prints a URL you can curl and a generated playground UI.

Calling Your App

The addressing model differs. Baseten routes by opaque IDs, combining model-{MODEL_ID} with a deployment or environment segment, and treats names as cosmetic. On fal the name is the address. Your app is your-username/sdxl-turbo, and it stays that across every deploy. See Calling Your Endpoints.

Migration Path 2: Custom Servers

If your Truss uses docker_server to run vLLM, SGLang, Triton, or your own FastAPI service, you do not need a fal.App at all. fal’s Direct Server Mode forwards traffic straight to a port in your container.
Two things change. exposed_port can be any valid port as long as your server binds the same one, so the port 8080 that Baseten reserves for its reverse proxy is free here. And predict_endpoint has no equivalent, because fal forwards all traffic to your container unchanged instead of mapping one inbound route onto one of your server’s routes. Your existing paths stay exactly as they are. To get the dashboard Playground and analytics, expose /openapi.json from your server. With a fal.App the spec is generated from your Pydantic models instead. Health checks also differ. Baseten polls the readiness_endpoint and liveness_endpoint you name in config.yaml. fal’s health check is an endpoint on a fal.App, declared as @fal.endpoint("/health", health_check=fal.HealthCheck(timeout_seconds=10)), so a Direct Server Mode migration has no direct equivalent for those two keys. See Deploy an Existing Server for private registry credentials and the full pyproject.toml schema. When you build from a Dockerfile rather than referencing a pushed image, the build context is the directory holding pyproject.toml, not the directory holding the Dockerfile. Write COPY paths relative to the project root:
A Truss has no equivalent step, because docker_server copies the Truss directory for you and there is no Dockerfile to write. If you would rather keep a fal.App wrapper and only bring your own image, use ContainerImage instead:
This also replaces system_packages and build_commands from config.yaml.

Migration Path 3: Chains

A Baseten Chain is several Chainlets, each with its own hardware, wired together with chains.depends() and deployed as one unit. fal has no single-deploy equivalent, and it does not need one: each step becomes an ordinary fal app, and a small CPU app routes between them.
FAL_KEY is injected into every runner automatically, so an app can call another app without you configuring credentials. Deploy each app separately with fal deploy. Chains already require typed inputs and outputs, so this part of the port is mechanical. A run_remote returning a bare dict is rejected at push time with an IO_TYPE_ERROR, which means your Chain already has Pydantic models, and those models carry over to fal.App endpoints unchanged. There are trade-offs in both directions. Chains give you one deploy, calls between steps that stay inside the platform, and a dependency graph it understands. Separate fal apps version, scale, and roll back independently, and any other app or client can call them directly, since each one is a normal endpoint rather than an internal node. The cost of that independence is a network hop per step. Every call from one fal app to another is an HTTP round trip through the gateway, where a Chainlet-to-Chainlet call is not. On a trivial two-step CPU pipeline the difference is visible: roughly 0.2s warm as a Chain against roughly 0.8s warm as two apps. Using fal_client.run instead of subscribe for the internal call does not close the gap, because the cost is the round trip rather than the queue. For steps doing real GPU work the overhead is a rounding error, but for chains of small, fast steps it is worth keeping the pipeline inside one app and calling plain Python methods between stages. See Multi-App Routing for routing by input size, A/B tests, and fallbacks.

Key Differences

Python Version

Almost every Truss sets python_version in config.yaml, and it is the one setting that does not become a fal.App class attribute. If you drop it and your pins were chosen for an older interpreter, the environment build fails on dependency resolution rather than on anything in your code:
Set it in pyproject.toml rather than in the class:
Then deploy by app key instead of by file reference:
Your local interpreter must match python_version. fal serializes your app code from the machine you deploy from, so a mismatch is rejected up front:
Install the fal CLI into a virtualenv on the version you are targeting and deploy from there. This has no analogue on Baseten, where truss push ships a directory and the interpreter is purely a server-side concern.
The alternative is to leave python_version unset and relax the pins your Truss inherited, letting the resolver pick versions that have wheels for fal’s interpreter. That is the better choice for a new app. Pin the version when you are migrating and want to reproduce a known-good environment exactly.

GPU Mapping

Baseten names accelerators by chip (accelerator: H100) and sizes CPU and memory separately. fal bundles each GPU with a fixed CPU and RAM allocation, so machine_type is the only knob. fal’s smallest GPU is the 40 GB A100, so models sized for a T4, L4, or A10G land on more VRAM than they had. For an accelerator not listed above, pick the smallest fal GPU your model fits in. For multi-GPU, the equivalent of Baseten’s instance_type: "H100:8x80" is num_gpus:
You can also list several machine types, tried in order, to widen the pool of machines your app can land on:
See Machine Types for the full table.
Expect your outputs to shift slightly. Because the GPU changes, a seeded generation that was bit-identical on Baseten will not reproduce byte-for-byte on fal. Running the example above on both platforms with the same prompt and seed gives images that are each perfectly reproducible on their own platform but differ from one another by a mean of 0.6/255 per channel (PSNR 43 dB). They are visually indistinguishable, but not the same bytes.If you have golden-image tests that assert on a hash, they will fail after migration. Assert on a perceptual distance threshold instead.

Autoscaling

The settings line up almost one for one. Baseten calls its units replicas; fal calls them runners. min_concurrency and max_concurrency are runner counts, not request counts, despite the names. They are the direct equivalents of Baseten’s min and max replica. The per-runner request count, which Baseten calls the concurrency target, is max_multiplexing. Two defaults are worth checking against your Baseten config before your first deploy. fal keeps idle runners for 60 seconds where Baseten waits 900, so a sporadically used app will cold-start far more often unless you raise keep_alive. And fal scales up immediately (scaling_delay = 0) rather than averaging over a 60-second window, which reacts faster but is more sensitive to brief spikes. Where Baseten reserves headroom as a percentage of a replica’s capacity, fal reserves it as a number of spare runners. concurrency_buffer sets a floor, concurrency_buffer_perc scales with request volume, and the effective buffer is whichever is larger.
As with Baseten’s autoscaling panel, you can change these without redeploying:
One behavior to know: runtime-tunable values set via CLI or dashboard persist across deploys and override what is in your code. fal deploy --reset-scale discards them. See Updating Your Configuration.

Queue-Based Inference

Baseten’s /async_predict and fal’s queue solve the same problem, and the fal version is the default path rather than a separate endpoint.
Three practical differences:
  • No separate route. subscribe and submit both go through the queue; subscribe just polls for you. Baseten needs you to choose /predict or /async_predict up front.
  • The input is not wrapped. Baseten nests your payload under model_input; fal sends it at the top level.
  • Status polling looks the same. handler.status() and handler.get() replace GET /async_request/{request_id}.
See Queue and Webhooks.

Secrets

Baseten requires secrets to be declared in config.yaml with a null placeholder and reads them from a dict passed to __init__. On fal, secrets arrive as environment variables and no declaration is required.
The one Baseten habit worth keeping is the explicit declaration. fal’s optional secrets allowlist limits a runner to the secrets you name, rather than injecting everything in the environment:
Note that a Truss reading secrets from a custom Docker image reads files at /secrets/{name}; on fal it is os.getenv in both cases. Secrets are injected at runner startup, so updating one requires a redeploy to take effect everywhere. See Secrets.

Model Weights and Caching

Baseten offers model_cache and external_data in config.yaml so that weights are fetched ahead of time rather than on every cold start. fal gives every runner a /data volume instead. It is a persistent filesystem shared across all your apps and runners, and it requires no configuration.
For Hugging Face libraries this is already done for you. fal sets HF_HOME to /data/.cache/huggingface, so from_pretrained calls in transformers, diffusers, and huggingface_hub cache to persistent storage automatically. This is why the SDXL-Turbo example above needs no cache configuration. The first runner downloads the weights, and every later runner reads them from /data. When several runners might write the same path at once, write to a temporary file in /data and os.rename it into place. See Persistent Storage and Downloading Models and Files.

Preprocess and Postprocess

Baseten’s preprocess and postprocess run in separate threads, outside predict_concurrency, so that downloading an input image does not occupy a GPU slot. fal has no equivalent hooks. Two approaches replace them:
  • Do the I/O inline. Raise max_multiplexing so a runner can overlap the I/O of one request with the GPU work of another. Your handlers must be safe to run concurrently.
  • Split the app. A CPU app fetches and normalizes inputs, then calls the GPU app. This is Multi-App Routing, and it keeps the expensive machine doing only GPU work.

Streaming and WebSockets

A Truss streams by returning a StreamingResponse from predict or postprocess. On fal, streaming is a distinct endpoint that returns SSE, so an app can offer both a buffered and a streaming route:
Baseten’s WebSocket support (runtime.transport set to websocket, or is_websocket_endpoint) maps to fal’s Realtime Endpoints. @fal.realtime("/realtime") uses a binary msgpack protocol built for back-to-back interactive requests; if you want the raw connection instead, use @fal.endpoint("/ws", is_websocket=True). See Streaming.

Deployments and Environments

Baseten distinguishes development deployments (mutable, live-reloading, created by truss push --watch) from published ones, and promotes a deployment into the production environment. fal’s model is flatter. There is no development-versus-published distinction, since fal run covers iteration and fal deploy publishes. Environments are namespaces rather than promotion targets. Each app belongs to exactly one, and you deploy the same code to each independently rather than promoting between them.
Rolling back does not require a promotion either. fal keeps previous revisions, and you can roll back to one directly. See Manage Environments and Deploy to Production.

CLI Mapping

Troubleshooting

Failures specific to arriving from a Truss, with the message each one produces. For failures during setup() or a request, read the runner logs with fal runners logs <runner-id> or the dashboard. Reproduce locally with fal run before redeploying.

Next Steps

Once you have migrated your app, the App Lifecycle page explains how the full lifecycle works on fal, from code serialization to runner shutdown. For the closest analogue to Baseten’s cold-start settings, see Optimizing Cold Starts. For scaling configuration, see Scale Your Application. For monitoring your deployed app, see App Analytics.