small: deploy to Railway in one command
small deploys small team apps to Railway in one command and protects them with sign-in by work account (Google,
Microsoft), work email or personal invite links.
small init my-service # skeleton: invite-link protected server, small.json, .gitignore
cd my-service
small deploy # → https://my-service-3f9a.up.railway.app
small open # open with the invite link
small access add ivan@company.com # who may sign in, like sharing a Google Doc
small sso google # "Sign in with Google" (or small sso microsoft)
small mail resend # or sign-in links by email
small access log # who signed in, when and how
small access add bob # or a personal link for bob (small access revoke bob to revoke it)
small notify telegram # access requests to Telegram
small env set DEBUG=1 # environment variable in Railway and small.json
small db add postgres # database, DATABASE_URL in the app
small volume add # persistent disk: files and sign-in state survive deploys
small domain add app.example.com # custom domain
small sleep on # sleep when idle (saves money)
small logs -f # app logs
small dashboard # all apps in the browser: status, cost, logs
small destroy # remove from Railway together with databases
You can also deploy an existing code folder (app.py, main.py, package.json and so on): small init is
optional.
Python 3.9+, no dependencies.
Install
pip install small-software
The command is small. With pipx: pipx install small-software.
From source, in a clone of the repository:
pip install ./small-cli
Windows (recommended): one command in PowerShell.
irm https://raw.githubusercontent.com/RuslanKazyradzi13/small-software/main/install.ps1 | iex
It installs small into its own Python environment in %LOCALAPPDATA%\Programs\small and adds a small
command that Smart App Control does not block (there is no unsigned small.exe). Needs Python 3.9+, no admin
rights; run it again to upgrade. Uninstall:
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/RuslanKazyradzi13/small-software/main/install.ps1))) -Uninstall
Values with & or | (URLs, for example) need PowerShell's stop-parsing token:
small --% env set "URL=https://x?a=1&b=2".
Windows with pip. pip install creates small.exe, which Smart App Control / Application Control may
block because it is unsigned. If that happens, either:
- run small through Python, which works everywhere:
python -m small_cli deploy(any command works this way); - or add the
small-clifolder of a clone toPATH, andsmall.cmdwill serve thesmallcommand. Run this in the root of the cloned repository ($PWDexpands to its path), then open a new terminal:
[Environment]::SetEnvironmentVariable("Path", $env:Path + ";$PWD\small-cli", "User")
Language
small speaks English and Russian. It uses Russian when the system language is Russian (LC_ALL, LC_MESSAGES,
LANG, LANGUAGE, or the Windows display language) and English otherwise. To choose explicitly, set SMALL_LANG
to en or ru:
export SMALL_LANG=en
$env:SMALL_LANG = "en"
Deployed apps pick their language separately:
- Sign-in pages and emails follow each visitor's browser language.
ACCESS_LANG=ruorACCESS_LANG=enforces one language. - App log lines and Telegram notifications are for the owner and follow
OWNER_LANG=en|ru.small deploysetsOWNER_LANGto small's own language unlesssmall.jsonsets it. It does this only whensmall.jsonhas at least one variable inenv(apps created bysmall initalways do); an app with an emptyenvgets no variables fromsmall deployat all.
Token
You need an account token or a workspace token (Railway → Account Settings → Tokens). A project token will not work: it cannot create projects.
Save the token to the RAILWAY_API_TOKEN variable, replacing YOUR_TOKEN entirely. Only the token itself, in the
form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, should remain inside the quotes:
[Environment]::SetEnvironmentVariable("RAILWAY_API_TOKEN", "YOUR_TOKEN", "User") # permanently
$env:RAILWAY_API_TOKEN = "YOUR_TOKEN" # this terminal only
On macOS / Linux:
export RAILWAY_API_TOKEN="YOUR_TOKEN" # add this line to ~/.bashrc or ~/.zshrc to keep it
A variable saved "permanently" is visible to new terminal windows. Terminals and apps that are already open do not
see it; restart them. small strips spaces, quotes and angle brackets around the token by itself.
Fallback variable name: RAILWAY_TOKEN.
small init: app skeleton
small init [folder] [--template python-http|node-http|fastapi|streamlit] [--name NAME] [--force]
Creates a ready-to-deploy micro-app in the folder (the current one by default; created if it does not exist).
--template (short: -t) picks the template, python-http by default:
| File | What it is |
|---|---|
app.py + magic_link.py (python-http, default) |
web server on the standard library (wsgiref); all your code goes in app() |
server.js + magic-link.js + package.json (node-http) |
web server on the http module, Node 18+; all your code goes in handler() |
main.py + magic_link.py + requirements.txt (fastapi) |
FastAPI + uvicorn API: a page, /api/hello, docs at /docs; protected by MagicLinkASGI, the token is hidden in uvicorn logs. Railway runs uvicorn main:app by itself |
app.py + requirements.txt (streamlit) |
interactive Streamlit app: sidebar with settings, metrics, a chart, a table with CSV download; invite-link sign-in is built into app.py |
small.json |
unique project name and a generated ACCESS_TOKEN in env |
.gitignore |
small.json, .small.json, .env and language clutter (__pycache__/, node_modules/…) |
.railwayignore |
what belongs in git but not in Railway: *.md, tests/, IDE settings |
The sample apps are in English: both the page text and the code comments.
- Invite-link protection. Templates are protected from the start, based on
magic-link.
small deploypassesACCESS_TOKENfromsmall.jsonto Railway. The app opens athttps://<domain>/?token=<ACCESS_TOKEN>; without the token visitors get a 403 page with a Request access button. The server listens on$HOST:$PORT, by default0.0.0.0and thePORTset by Railway. - The
streamlittemplate. Streamlit can't be wrapped in middleware, sorequire_access()inapp.pyusesMagicLinkGatefrommagic_link.py: the same variables (ACCESS_EMAILS,ACCESS_TOKEN,SESSION_SECRET,ACCESS_REQUEST_URL...), one-time links, limits and logs as the other templates, and the access screen follows the visitor's browser language. Differences:- Sign-in by email: the link leads to
https://<domain>/?t=...and shows a Sign in button; only the click signs in. The 30-day session is a signed cookie written by a tiny same-origin frame, so unlike in the other templates it is not HttpOnly: don't render untrusted HTML in a protected app. Sign out is in the sidebar. - Personal links keep the token in the address bar (
?token=), which is how they survive a page reload. - Streamlit is a single-page app, so the "no access" screen is served with HTTP 200, not 403.
Railway runs
python app.py, andapp.pyrestarts itself throughstreamlit runon$HOST:$PORT. Run it locally the same way:python app.py.
- Sign-in by email: the link leads to
- Name. By default, the folder name plus a random suffix, for example
my-service-3f9a. An explicit name insmall.jsonbinds deploys to the Railway project with the same name, so the name must not accidentally match someone else's project. The suffix also makes it more likely that the<name>.up.railway.appsubdomain is free.--namesets a name without the suffix. small.jsonis added to.gitignorebecause it holds the token. Do not publish it.
Existing files are not overwritten. If the folder already has files the template creates:
- in a terminal,
small initlists them and asksOverwrite? [y/N]; - without a terminal (script, CI), the command fails. Either way, if you decline, nothing in the folder changes.
--force overwrites without asking, including small.json: you get a new name and a new ACCESS_TOKEN, and old
invite links stop working after the next deploy. .gitignore and .railwayignore are never overwritten: only
missing lines are appended, so running init again does not duplicate anything.
small dashboard: all apps in the browser
small dashboard [folder] [--port N] [--no-browser]
Starts a local server and opens a browser with all micro-apps in a folder: the current one by default, or any
other, for example small dashboard ... An app is a folder with small.json and/or .small.json.
For each app the dashboard shows:
- name: from
small.json, otherwise from.small.json, otherwise the folder name; - status: Linked to Railway (
.small.jsonhas a service ID) or Not deployed; - live status of the latest deploy from Railway (
SUCCESS,BUILDING,FAILED,CRASHED…), if a token is set; - auto-sleep: sleep mode is on (
small sleep on); - cost estimate in $/month at the current load (see below), with the total for all apps in the summary line;
- the public URL and a link to the service in the Railway console, if the app has been deployed;
- the folder path;
- names of the variables from
small.json, without values; - errors in
small.json/.small.json: the same checks assmall deployruns.
The Refresh button rescans the folders without a restart. Stop the server with Ctrl+C.
Buttons on the card of a deployed app:
-
Open via invite link opens the app right away with the owner's token, skipping the "invite only" screen. The token only goes to the browser's address bar on navigation; it is not in the dashboard page data.
-
Logs opens a window with the last 200 lines of the app or build log (tabs App / Build). Requires
RAILWAY_API_TOKEN; without it the button is disabled. -
Share opens who has access: work emails, whole domains and personal-link names (never the links themselves). Type
ivan@company.com,@company.comor a name and click Add, or click Revoke next to anyone. It does exactly whatsmall access add/revokedoes: updates small.json and Railway and restarts the app. A new personal link is shown only once, with a Copy button, and works like a password. RequiresRAILWAY_API_TOKEN. The page and its requests are protected: a per-run key,Host/Originchecks, no framing, JSON only. -
Port 3000 by default; if it is busy, any free port is used. The URL is printed to stdout.
-
The server listens on
127.0.0.1only and answers 403 to requests with a foreignHost(DNS rebinding protection). Values of the variables fromsmall.jsonare never served: they includeACCESS_TOKEN. -
Search goes up to 6 levels deep.
.git,node_modules,__pycache__,.venvandvenvare skipped, and the scanner does not descend into an app it has found. -
Styles are loaded from the Tailwind CDN; without internet access the page works, just unstyled.
Live deploy statuses
If RAILWAY_API_TOKEN (or RAILWAY_TOKEN) is set at startup, on every refresh the dashboard asks Railway for the
latest deploy of each linked app with the serviceInstance(serviceId, environmentId) { latestDeployment } query.
The status is shown as a colored badge:
| Color | Statuses |
|---|---|
| green | SUCCESS |
| light blue | SLEEPING |
| pulsing yellow | QUEUED, WAITING, INITIALIZING, BUILDING, DEPLOYING |
| red | FAILED, CRASHED |
| gray | everything else |
The badge tooltip shows the deploy time.
- Without a token the dashboard works offline from local files and never calls Railway.
- Errors (no network, the 5 s timeout, a wrong token, a deleted service) do not crash the server: those cards simply stay without a live status, and the summary line notes that Railway didn't respond.
- Speed. Requests run in parallel, so even a slow Railway delays a refresh by no more than the timeout.
- Security. The Railway token stays in the dashboard process and is never sent to the browser.
Cost estimate
The dashboard takes the project's load measurements for the past week from Railway (the metrics query: average
CPU cores, GB of memory and GB of volumes per hour, including the app's databases) and multiplies the average load
by the monthly prices from the Railway docs: $20 per vCPU,
$10 per GB of memory, $0.15 per GB of volume. The result reads "≈ $X/mo at current load".
- Sleep and new apps. Hours when the app was asleep (
small sleep) count as zero. For a new app the average starts at its first measurement, so the estimate is not understated. - Why not
usage. The accumulatedusagearrives with a delay. In a live check it differed from the actual measurements by 7–50×, and differently for CPU and memory, so an honest total cannot be computed from it. - This is an estimate, not a bill. Egress ($0.05/GB), the plan's free allowance ($5 on Hobby, $20 on Pro) and the subscription are not included. The exact amount is in Railway → Usage.
small mcp: AI agents (Claude Code, Cursor...)
small mcp
Runs an MCP server on stdin/stdout, so an AI coding agent can create, deploy and share apps itself: "Deploy this folder and give ivan@company.com access."
Connect it:
-
Claude Code:
claude mcp add small -- small mcp(Windows:claude mcp add small -- cmd /c small mcp). -
Cursor (
~/.cursor/mcp.json), Claude Desktop, Windsurf and other clients:{"mcpServers": {"small": {"command": "small", "args": ["mcp"]}}}
On Windows use
"command": "cmd", "args": ["/c", "small", "mcp"], or"command": "python", "args": ["-m", "small_cli", "mcp"]if small was installed with pip. -
RAILWAY_API_TOKENmust be in the environment the agent starts from. After setting it, restart the agent.
Tools:
| Tool | What it does |
|---|---|
init_app |
small init: a ready app from a template |
deploy_app |
small deploy: returns the URL; progress is streamed while it builds |
share_app |
small access add: an email, @domain or a personal-link name |
revoke_access |
small access revoke |
list_access |
small access list |
list_apps |
apps under a folder with URL and latest deploy status |
app_logs |
small logs: app or build log |
set_env |
small env set / unset |
- No delete tool:
small destroystays a terminal command. - Prefer emails. A personal link (a name instead of an email) is returned to the agent, so it passes through the chat like any other text; with email sign-in nothing secret does.
- The tools run the same code as the commands and answer in small's language (
SMALL_LANG). Your MCP client normally asks you before each tool call.
small deploy
small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--public] [--new] [--no-wait] [--timeout SEC]
| Flag | What it does |
|---|---|
--name |
project, service and subdomain name; overrides name from small.json (default: the folder name normalized to a-z0-9-) |
--workspace |
workspace ID if the account has several (or RAILWAY_WORKSPACE_ID) |
--port |
port for the public domain if the app does not listen on $PORT |
-m, --message |
deploy message shown in Railway |
--public |
an app without sign-in of its own: deploy it open to anyone, without the gateway (see small gateway) |
--new |
create a new project even if the folder has been deployed before |
--no-wait |
print the URL right away without waiting for the build |
--timeout |
how many seconds to wait for the build (default 900) |
Progress goes to stderr and the final URL to stdout: URL=$(small deploy).
Exit codes: 0 success, 1 error or failed build (the last log lines are printed), 130 interrupted.
small gateway: sign-in in front of any app
small gateway [on [--port N] | off [--yes]] # no subcommand: the status
The gateway protects an app that has no sign-in of its own — any language, any framework, a Dockerfile — without
changing its code. It is a second service in the app's Railway project, <app>-gateway: Caddy
takes every request, and magic-link next to it decides who gets in (Google and Microsoft sign-on, email links, invite
links, all small access / small sso / small mail settings). Requests it lets in go on to the app over Railway's
private network; the app itself has no public address.
- Private by default. The first
small deployof a folder withoutmagic_link.py/magic-link.jssets the gateway up before the app ever gets an address, and puts your own invite link insmall.json(small open).small deploy --publicdeploys such an app open to anyone instead; redeploying an app that is open prints a warning. onfor an app already deployed: creates the gateway, moves the sign-in variables to it, moves the address (keeping its name), and restarts the app without a public address. The app must listen on$PORT(small sets it to8080if it isn't set) or pass--port. A custom domain has to be removed first and added back after; it then points to the gateway.- Who is signed in. The gateway adds
X-Small-User(the address, or the invite-link name),X-Small-Method(email,google,microsoft,link) andX-Small-Auth, an HMAC signature of both withSMALL_GATEWAY_KEY, which the app gets as a Railway reference. AnyX-Small-*header a visitor sends is dropped. magic-link in the app (0.9+) trusts that signature and nothing else, so an app that keeps its own sign-in code works behind the gateway too. - Websockets go through the same check. For Streamlit this means an
HttpOnlysession cookie set by the gateway. - Where things live. Sign-in variables (
ACCESS_*,SESSION_SECRET,GOOGLE_*,MICROSOFT_*,SESSIONS_REVOKED…) belong to the gateway; mail and Telegram variables go to both; everything else stays the app's.small env,small access,small sso,small mailandsmall deployroute them for you, and restart only the service that changed. - The rest follows:
small access logreads the gateway's log,small logs --gatewayshows it,small sleepputs both to sleep (the first request wakes both, a few seconds),small domainattaches to the gateway,small volume add --gatewaykeeps sign-in state across the gateway's restarts,small destroyremoves both. small deployalso rebuilds the gateway when it was built by an older small.offgives the address and the variables back to the app and deletes the gateway. If the app has no sign-in of its own, small asks first (or--yes): without the gateway anyone with the address gets in.- Cost: one more small service. Caddy and the sign-in server idle at a few tens of MB; with sleep on, both sleep.
After deploy
These commands work in the folder of a deployed app, that is, where .small.json is. link, open, logs and
destroy take the path as an argument; the others take it as -C folder.
small link [folder] [--as NAME] # print the invite link
small open [folder] [--as NAME] # the same + open it in the browser
small access [list | add EMAIL|@DOMAIN|NAME | revoke EMAIL|NAME] # who can sign in
small access log [-n N] [--json] # who signed in, when and how
small access signout EMAIL|@DOMAIN|--all # end their sessions on all devices
small sso [google [--client-id ID] | microsoft [--client-id ID] [--tenant T] | off [PROVIDER]]
small mail [resend | smtp [--from FROM] [--to TO] | log | off] # emails for sign-in by email
small notify [telegram [--chat-id ID] | off] # access requests to Telegram
small env [list [--values] | set K=V ... | unset K ...] [--no-deploy]
small db [list | add postgres | remove postgres [--yes]]
small volume [add [--mount /data]] [--gateway] # persistent disk; no argument shows the status
small gateway [on [--port N] | off [--yes]] # sign-in in front of any app (see above)
small domain [list | add DOMAIN [--port N] | remove DOMAIN]
small sleep [on | off] # sleep mode; no argument shows the status
small logs [folder] [--build | --gateway] [-n N] [-f] # log of the latest deploy
small destroy [folder] [--yes] # remove from Railway
Commands that change the app's variables (access add / revoke / signout, sso, mail, notify,
env set / unset, db add / remove), volume add and sleep on / off restart the app once. Add --no-deploy to skip the restart; the change
then takes effect on the next deploy.
small link / small open build https://<domain>/?token=<token>.
- Where the token comes from. From
small.json, or, if it is not there (for example, the token was set in the Railway dashboard), from the service variables in Railway; that requiresRAILWAY_API_TOKEN. Without--asthe first token (the owner's) is used;--as bobgives bob's personal token. - If there is no token anywhere, the address is printed without a token.
- Output. The link goes to stdout, so you can pass it on:
small link | clip. It works like a password, so send it only to people who need access.
small access controls who can open the app. There are two ways, and you can combine them.
By address, like sharing a Google Doc. This is the recommended way:
add ivan@company.comadds the address toACCESS_EMAILS. Ivan opens the app and proves the address is his: with Sign in with Google / Microsoft (small sso), or by entering it and getting a sign-in link by email (small mail) that works for 15 minutes and only once. The browser remembers the sign-in for 30 days. Passing a link on gives nobody anything: only the owner of the mailbox or account gets in.add @company.comlets in everyone with an address in that domain.revoke ivan@company.comcloses sign-in right after the restart, even in a browser that is already open. The time also goes toSESSIONS_REVOKED, so if you add the address back later, the old sessions stay invalid.signout ivan@company.com(or@company.com, or--all) ends the sessions on every device without taking access away: for a lost laptop. People can do it themselves with Sign out on all devices if the app has a volume (small volume add).logprints who signed in, when, how (Google, Microsoft, email link, invite link) and from which IP, plus refused sign-ins and access requests, across deploys:magic_linkwrites them as JSON log lines, and Railway keeps them as long as your plan keeps logs.--jsongives one object per line.- The first
addcreatesSESSION_SECRET, the key that signs links and sessions. One way to sign in must be set up:small ssoorsmall mail. smallpasses the address for links in emails to the app itself, inPUBLIC_URL(in Railway only): after the domain is renamed to<name>.up.railway.app, Railway keeps the old address inRAILWAY_PUBLIC_DOMAIN, and the links would lead to a 404. If you want links on your own domain, setPUBLIC_URLinsmall.json;smalldoes not touch it.- Someone who is not on the list and enters their email gets the same "Check your email" response, and the owner
receives an access request (in the log, and in Telegram if connected) with a ready-made
small access add their@addresscommand. - Works in all templates:
python-http,fastapi,node-httpandstreamlit(see thestreamlitnotes above).
By personal invite link:
-
add bobcreates a token, adds it toACCESS_TOKENasbob:token, restarts the app and prints the link. The link works like a password: anyone it is forwarded to can get in. -
revoke bob: after the restart neither bob's link nor the cookie he already has works; everyone else keeps their access. -
listshows who has access: addresses and names (tokens are hidden). -
Sign-in log.
small access log(above). In the raw log (small logs) each sign-in is one JSON line, for example{"message": "[magic-link] signed in with Google: ivan@company.com, ip 203.0.113.7", "magic_link": "sign_in", "method": "google", ...}; Railway's log explorer filters them with@magic_link:sign_in.
small sso sets up sign-in with a work account. Who may sign in is still the access list.
google: "Sign in with Google" for Google Workspace accounts and Gmail addresses.smallprints what to do in Google Cloud (about 5 minutes: an OAuth client of type Web application) and the exact redirect URI to register, then asks for the client ID and secret (the secret fromGOOGLE_CLIENT_SECRETor with hidden input).microsoft: "Sign in with Microsoft" for the members of one Microsoft Entra organization.smallprints the steps (an app registration, single tenant, a client secret) and the redirect URI;--tenanttakes the Directory (tenant) ID or your domain (company.com), whichsmallresolves to the ID.- Checked before saving.
smallshows the provider the client ID and secret; a wrong pair is refused and nothing is saved. - Which accounts count. Google: verified Gmail addresses, or accounts of a Google Workspace organization; a personal Google account registered with a company address is refused. Microsoft: members of your tenant only, not guests and not other organizations. Details: SECURITY.md.
- Redirect URI:
https://<app>/_access/sso/callback, orhttps://<app>/forstreamlit. With your own domain, setPUBLIC_URLinsmall.jsonfirst, and register that address. - If email sign-in isn't set up, the sign-in page shows only the provider buttons; with both, it shows both.
off [google|microsoft]turns one or both off; with no subcommand you get the status and the redirect URI.
small mail sets how the app sends sign-in emails.
resendsends through Resend over HTTPS.smalltakes the key fromRESEND_API_KEYor asks for it with hidden input. Railway's Hobby plan blocks SMTP ports, so on Hobby this is the main option (Railway docs). Without a domain verified in Resend, emails go only to your Resend account's address; for colleagues, verify a domain and pass--from "Reports <noreply@company.com>".smtpuses your own mail server:SMTP_URLin the formsmtp://user:password@server:587(orsmtps://…:465), taken from the environment or asked for with hidden input. On Railway it works from the Pro plan.- Before saving,
resendandsmtpsend a test email (--to address) with the same code the app uses. If it does not go through, nothing is saved and the reason is printed.--fromand--towork with both. logwrites the sign-in link to the app log instead of sending an email. For testing only: a link from the log lets you into the app.offturns email off; with no subcommand you get the status (resend,smtp,logoroff).
small notify sends access requests to the owner in Telegram.
telegramconnects a bot. Create one with @BotFather (/newbot).smalltakes the bot token fromTELEGRAM_BOT_TOKENor asks for it with hidden input, so it does not end up in your terminal history. Thensmallasks you to send the bot any message, finds the chat ID itself, sends a test notification and writesTELEGRAM_BOT_TOKENandTELEGRAM_CHAT_IDto Railway andsmall.json. You can pass the chat ID right away with--chat-id.- What a notification looks like. When someone clicks Request access and leaves a contact, you get a
message with the app's address, the contact, the IP and a hint:
small access add NAME. Works in all templates (python-http,fastapi,node-http,streamlit) and inmagic-linkin general. The request is still written to the log as well. offdisconnects the bot; with no subcommand you get the status.
small db adds a database in one step.
add postgresdeploys Postgres from the Railway template (likerailway add --database postgres) into the app's project and passesDATABASE_URLto the app as the Railway reference${{Postgres.DATABASE_URL}}. The password is stored only in Railway;small.jsongets just the reference. The app reaches the database over Railway's private network.- Startup. The database takes about a minute to start: if the app connects to it at startup, the first start may happen before the database is ready.
listshows the app's databases and their status.remove postgresdeletes the database together with its data (confirm by typing the name, or pass--yes) and removesDATABASE_URL.small destroydeletes the databases together with the app.
small domain sets up a custom domain instead of *.up.railway.app.
add app.example.comregisters the domain in Railway and prints the DNS records for your registrar: a CNAME pointing to the Railway address and, if Railway asks for it, a TXT record that verifies ownership.- Certificate. Once the records are live (from minutes to a day), Railway issues the HTTPS certificate by itself.
- Invite links work on the custom domain too:
https://app.example.com/?token=…. listshows the domains and their state (waiting for DNS records / DNS configured) and the certificate status.removeremoves the domain.
small volume gives the app a persistent disk (a Railway volume).
addcreates one at/data(--mountfor another path, outside/app) and restarts the app. The app can keep files there; the path is inRAILWAY_VOLUME_MOUNT_PATH. With no subcommand you get the path and how much is used.- Sign-in state.
magic_linkkeeps it in.magic-link.db(SQLite) on the volume: used sign-in links stay used after a restart, rate limits are shared by worker processes, and Sign out on all devices appears. Without a volume this state lives in memory and a restart forgets it. - Trade-off. With a volume each deploy has a few seconds of downtime: two copies of the app can't share the disk. A service has one volume.
small sleep controls Railway's sleep mode (serverless).
on: with no incoming requests the app goes to sleep and uses no CPU or memory; the first request wakes it up in a few seconds. Handy for apps that are opened a couple of times a day.off: the app always runs. With no argument you get the current mode.- Restart. The setting takes effect after a restart, which
small sleepdoes by itself (--no-deploypostpones it until the next deploy).
small env manages the app's environment variables.
listshows the variables and where they are set:small.json + Railway, Railway only, orRailway ≠ small.json. Values are hidden;--valuesshows them. InternalRAILWAY_*variables are not listed.set/unsetchange variables in both Railway andsmall.jsonat once; otherwise the nextsmall deploywould bring back the old values. After a change Railway restarts the app once;--no-deployturns that off. If the app has not been deployed yet, onlysmall.jsonchanges.- Removed variables. If you removed a variable from
small.jsonby hand, delete it withsmall env unsetso that it disappears from Railway too.
small logs shows the log of the latest deploy.
- Which log. The app log by default, the build log with
--build, the gateway's with--gateway.-nsets the number of lines (default 100). - Follow mode.
-fappends new lines every 2 seconds;Ctrl+Cexits. - Format. Every line is printed with its local time. Requests from the Request access button show up as
[access-request] ..., and they are easy to filter:small logs | findstr access-request. - Limitation.
-ffollows the deploy that was the latest when the command started; after a new deploy, run the command again.
small destroy removes the app from Railway.
- What gets deleted. If the project contains only this app's services (the service itself and the databases
from
small db), the whole project is deleted: services, domains, variables, deploys, database data. Ifsmall deployonce linked the folder to a project that also has other services, only the app's services are deleted and the rest stay. - Confirmation. In a terminal you have to type the project name. Without a terminal (script, CI) the app is
deleted only with the
--yesflag. If you decline, nothing is deleted. - Local files. After the deletion
.small.jsonis removed, whilesmall.jsonstays, so the nextsmall deploycreates a new project with the same settings.
Settings: small.json
An optional file in the root of the app folder:
{
"name": "my-custom-app-name",
"env": {
"OPENAI_API_KEY": "sk-...",
"DEBUG": "true"
}
}
| Field | What it does |
|---|---|
name |
Project and service name in Railway, and the desired subdomain <name>.up.railway.app. Normalized to a-z0-9-. The --name flag takes precedence. |
env |
The service's environment variables. Names: Latin letters, digits and _, not starting with a digit. Values: strings, numbers or true/false (sent to Railway as strings: "true", "2"). |
Both fields are optional; any other field is an error (this catches typos like "envs"). An error in the file
stops the deploy before any request to Railway.
Name. While the folder is not linked to a service (there is no .small.json), an explicitly set name (from
small.json or --name) works as an identifier: if Railway already has a project with that name, for example
after a deploy from another computer, small connects to it instead of creating a duplicate. If there is no such
project, it is created. If there are several projects with that name, the deploy stops with an error. --new
always creates a new project. If the folder is already linked, a new name is not applied, and small warns you
about it. To link the folder to another project, delete .small.json.
Variables are written on every small deploy, before the code is uploaded, so the new build sees them right
away. small uses variableCollectionUpsert, the batch form of variableUpsert, with replace: false: variables
from env are added or updated, and the service's other variables (for example, an ACCESS_TOKEN set in the
dashboard) are left alone. A variable removed from env stays in Railway: delete it with small env unset
or in the dashboard (service → Variables). Only variable names are printed, never values.
Unless env sets them itself, small deploy also passes the app PUBLIC_URL (when sign-in by email is on, see
small access) and OWNER_LANG (small's language, see "Language" above). With an empty env, small deploy
writes no variables at all, OWNER_LANG included.
Secrets. The root small.json is not uploaded to Railway with the code. If it holds keys, add it to
.gitignore so that it does not end up in git.
small.json and .small.json are different files. You write the first one yourself (settings). small creates
the second one: it holds the project and service IDs and the domain.
Under the hood
small.jsonis read and validated, if it exists.- The folder's code is packed into a
.tar.gz. The root.gitignoreand.railwayignoreare respected;.git,node_modules,__pycache__,.venv,venv, as well assmall.jsonand.small.jsonin the root, are always excluded. - GraphQL at
https://backboard.railway.com/graphql/v2:me.workspaces(picking the workspace). If the name is set explicitly,projectslooks for a project with that name. ThenprojectCreateandserviceCreate, if there is no matching project. serviceDomainCreatecreates a*.up.railway.appdomain. If<name>.up.railway.appis free (serviceDomainAvailable), the domain is renamed to it (serviceDomainUpdate). If the name is taken, the domain Railway assigned stays. If the service was found by name and already has a domain, the existing one is used. The domain is created before the upload because the very first build needs its address (links in sign-in emails).variableCollectionUpsertwrites the variables fromsmall.json, plusPUBLIC_URLandOWNER_LANG(see above). With an emptyenvthis step is skipped.- The archive goes to
POST https://backboard.railway.com/project/{projectId}/environment/{environmentId}/up?serviceId=.... This is the same endpoint the officialrailway upuses. It starts the build and returns adeploymentId. deployment(id) { status }is polled every 3 s untilSUCCESS(orSLEEPING) or a failure status:FAILED,CRASHED,REMOVED,REMOVING,SKIPPED,NEEDS_APPROVAL. OnFAILEDthebuildLogsare printed, on any other failure thedeploymentLogs.https://<domain>is printed.
The project and service IDs and the domain are saved to .small.json in the app folder. The next small deploy
ships a new version to the same service and does not create a new project. The file is written right after the
project is created or linked, so a failed build does not spawn extra projects when you retry.
The build is done by Railpack, which detects the language by itself. For Python, app.py or main.py is enough
(plus requirements.txt for dependencies), and the start command will be python app.py. The app must listen on
0.0.0.0:$PORT.
Tests
In the small-cli folder of a clone:
python -m unittest discover -s tests
The tests start a local mock of the Railway API (GraphQL + the upload endpoint) and run the full scenario: first deploy, repeat deploy, failed build, crash, taken domain, several workspaces, wrong token, ignore rules.
Template tests on real frameworks are skipped if the framework is not installed; the rest pass.
streamlittests: the access gate throughstreamlit.testing.v1.AppTest(personal tokens, sign-in by email: form, one-time link, session cookie, revocation, sign out; sign-in with Google against a local provider, refusals, sign out on all devices) and the server throughpython app.py.- 2
fastapitests:TestClientanduvicorn main:appthe way Railway runs it, plus a check that the token is hidden in the access log.
small env and small access are tested against the Railway mock:
- variable sources and hidden values;
set/unsettogether withsmall.json, a single restart,--no-deploy;- before the first deploy;
- a Railway failure that leaves
small.jsonunchanged; - granting, revoking and
link --as, revoking the last token, a token that exists only in Railway.
Dashboard buttons: the "Open via invite link" redirect and its protection against foreign paths, "Logs" without a token, with Railway and with Railway unavailable. Sleep mode and the cost estimate in the dashboard, Railway errors while computing the cost.
small sso, volume, access log and access signout are tested against mocks of Railway, Google and Microsoft:
the credential check, a wrong secret that saves nothing, resolving a domain to a tenant, the Streamlit and custom
domain redirect URIs, old magic_link code, the volume and its mount path, the access log table, --json and the MCP
tool, sign-outs by address, domain and --all, forgetting old sign-outs.
small sleep, db, domain and notify are tested against mocks of Railway and the Telegram Bot API:
- turning sleep on and off, turning it on again without requests,
--no-deploy; - deploying the Postgres template with default values, the
DATABASE_URLreference, a workflow error, removing the database,destroytogether with the database; - DNS records for a custom domain, address normalization, repeat and removal;
- finding the bot's chat, the test message, a wrong token, "nobody messaged the bot", a missing token.
small init is tested like this (the streamlit template too):
- the
python-httpandnode-httptemplates and unique names and tokens (fastapiandstreamlithave their own tests, see above); - protection of existing files: declining,
--force, the terminal prompt; - appending to
.gitignorewithout duplicates; - the
init→deploychain; python-httpandnode-httprun as real servers (403 → link → cookie → 200);- the copies of
magic_link.pyandmagic-link.jsin the templates match the sources in the repository'smagic-link/folder.
small dashboard is tested like this (live statuses against the Railway mock: success, partial failure, no
network, timeout, wrong token, garbage status, no token in the response):
- scanning: nested folders, service folders, folders inside an app, the depth limit, an app at the root;
- app descriptions and no variable values in the output;
- broken files and unsafe links;
- the page and the API, including a rescan;
- 403 for a foreign
Host; - the fallback port;
- the command itself: URL, browser,
--no-browser.
small.json is tested separately:
- name and variables, the "variables before upload" order;
- excluding the file from the archive;
- linking to an existing project and an ambiguous name;
--newand the precedence of--name;- an already linked folder and an invalid file.
SMALL_API_URL overrides the API address.
Limitations
- Only the root
.gitignore/.railwayignoreare read; nested ones are ignored. - One service is created (two with the gateway), in the
productionenvironment. Databases: Postgres only (small db).
Metadata
Release files for small-software 0.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| small_software-0.9.0.tar.gz | 310.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| small_software-0.9.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 565.5 kB
Release files / small_software-0.9.0.tar.gz
| Download URL | small_software-0.9.0.tar.gz |
|---|---|
| Size | 310.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b443ef7bae3ed759b3717b3089f93fe9c066529f9dc51b6f9aa27f9307fb54d4
|
|
BLAKE2b-256 checksum How to use checksums |
cb8885cf87142c307f08b1b63fffee0c34066325f6bbb544c3b669ffe5d3b12e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency logRelease files / small_software-0.9.0-py3-none-any.whl
| Download URL | small_software-0.9.0-py3-none-any.whl |
|---|---|
| Size | 255.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f9fa6158ea898a2cedcad4763e8b919e5cd25ebf890b134281f49743fca7092a
|
|
BLAKE2b-256 checksum How to use checksums |
cb6369695f60f30377b886799698c4a30ec9d88115e0f80679f8ca32023e00bf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.
Transparency log