Callables and Railway: deploy your app on one, run your backend tools on the other
Most of our comparison posts help you choose between Callables and something else. This one doesn't, because you shouldn't choose. Railway is where you deploy your apps and services, and we like it enough to run the Callables web app and API on it. Callables is for the automations that sit around those services: the scripts and commands you use to operate them.
If you're already on Railway, you don't need to move anything. Here's where each one fits.
Different jobs
Railway runs your services. Your API, your workers, your databases, your cron jobs. It builds from your repo, keeps things online, scales them, and gives you environments for production and staging.
Callables runs your actions. A refund, a data fix, a plan change, a weekly report. These are short jobs that someone starts on purpose, or that run on a schedule. Each one is a Python function, and Callables gives it a form, permissions and a history of every run.
Your app is what your customers use. Callables is what your team uses to look after it.
You probably already have the commands
Odds are your backend has a pile of commands behind it: a scripts/ folder, a management command, a Typer CLI. That's how a credit gets applied or a stuck job gets retried. On Railway, running one looks something like this:
railway ssh --service api --environment production -- python scripts/apply_credit.py --account acme --amount 50
It works, but it only works for you. Whoever runs it needs the CLI, a login to the project and the right flags. Support asks an engineer in Slack, the engineer runs it, and the only record is in that person's terminal history.
Add a decorator to the same code in the same repo, and it becomes a tool:
from typing import Literal
from callables import Group
accounts = Group("accounts", title="Accounts", secrets=["DATABASE_URL"])
@accounts.deploy(title="Apply credit")
def apply_credit(
account: str,
amount: int,
reason: Literal["goodwill", "outage", "billing error"] = "goodwill",
dry_run: bool = True,
) -> str:
"""Adds a credit to the account and notes why."""
...
Push it, and your team has a form with a text box, a number, a dropdown and a checkbox. Every run keeps who ran it, what they entered, the logs and the result. You still have a command too: call accounts apply_credit --account acme --amount 50 works from your terminal, and it reads the same code. See turning a script into a web form.
One set of tools per environment
Railway keeps production and staging apart, with separate variables and networks. Your tools should follow the same line, so nobody fixes staging data in production by mistake.
A Callables project is one repo and one branch, and you can connect the same repo more than once. To mirror your Railway environments, connect each branch as its own project:
mainbecomes your production project, with production'sDATABASE_URL.stagingbecomes your staging project, with staging'sDATABASE_URL.
Each project deploys when its branch does, keeps its own secrets, and has its own sharing and run history. You can give the whole team the staging tools and keep the production ones to a few people. The function doesn't change, because it just reads DATABASE_URL.
Spend your time on the app
The alternative is building this yourself. The first version of an internal admin page is quick. Then it needs login, then roles, then a log of who did what, then a form for the next task, and someone has to keep all of it working. None of that is what your customers pay for.
With Callables, those parts come with every function:
- Forms come from the function's type hints, so there's no front end to write. See building an admin panel in Python.
- Access is set per function. Support can look up any account while only two people can issue credits. See permissions.
- Run history records every run with its inputs and result, and people who run a function never see its secrets.
- Schedules work on any function, in your own time zone, and each run lands in the same history.
Railway's roles decide who can work on the project. Callables decides who can do one specific thing to it, which means the person issuing a refund doesn't need access to your infrastructure at all.
Connecting the two
Callables runs your functions on its own machines, not inside your Railway project. Railway's private network isn't reachable from outside, so internal addresses like the default DATABASE_URL won't work. There are two good ways to connect:
- Call your own API. If your service already exposes the logic, add an internal endpoint behind a token, and have the function call it. Your business logic stays in one place, and you can keep your database private.
- Connect to the database directly. Add Public Access to the database on Railway, then set its
DATABASE_PUBLIC_URLas the secret's value in Callables. Railway bills this traffic as network egress, and your database is then reachable from the internet, so use a strong password and a limited database user.
Reusing your app's code is usually as simple as importing it, since the function lives in the same repo. One thing to watch: Callables imports your modules at build time, when secrets aren't set. If a module reads os.environ["DATABASE_URL"] as it's imported, move that read inside a function. Run call check before you push to catch problems like this.
What goes where
| The job | Where it belongs |
|---|---|
| Your API, website and customer-facing services | Railway |
| Databases, queues and background workers | Railway |
| Long batch jobs over 15 minutes | Railway |
| Anything your app depends on to keep working | Railway |
| One-off fixes and support actions | Callables |
| Tools for people who don't use a terminal | Callables |
| Reports and scheduled jobs someone needs to check | Callables |
| Anything where you need to know who ran what | Callables |
Callables only runs Python, and a run can last up to 15 minutes, so it won't replace a worker or a long job. It isn't meant to.
Getting started
Pick the command you get asked to run most often. Add @deploy to it, connect the repo, and set its secrets. Keep deploying your app the way you do today. The quickstart takes a few minutes, and every workspace starts with a 30-day trial with no card required.
Turn your scripts into tools your team can run
Add @deploy to a Python function and push. Your team gets a form, schedules and run history.