September 3, 2026Engineering10 min read
How I Deploy a Stateful FastAPI Side Project on Railway with Persistent Volumes
A practical look at how I deploy SpiderMind: a FastAPI backend on Railway, persistent SQLite state on a Railway Volume, a separate read-only Postgres data source, and a Next.js frontend on Vercel.
Deploying a stateless FastAPI API is easy.
Deploying a FastAPI application that actually needs to remember things between deployments is where the architecture becomes more interesting.
I ran into this while deploying SpiderMind, a personal context-aware web intelligence system I have been building.
SpiderMind is not a simple request-in, response-out API. It maintains its own state, stores generated data, runs recurring background workflows, and also reads from a separate website database to keep part of its context synchronized with my personal site.
My current deployment looks roughly like this:
┌─────────────────────┐
│ Next.js frontend │
│ Vercel │
└──────────┬──────────┘
│
│ HTTPS
▼
┌─────────────────────┐
│ FastAPI backend │
│ Railway │
└───────┬──────┬──────┘
│ │
own state │ read-only sync
│ │
▼ ▼
┌────────────┐ ┌──────────────┐
│ SQLite │ │ Postgres │
│ Railway │ │ website data │
│ Volume │ │ source │
└────────────┘ └──────────────┘
This setup has been a useful middle ground for a personal system: simple enough to operate alone, but stateful enough to behave like a real application.
Here is how I structured it.
What SpiderMind needs from its backend
SpiderMind scans public technical sources and turns relevant signals into context-aware opportunity briefs and writing drafts.
The important deployment detail is that the backend owns application state.
It stores things such as:
- internal memory
- scan history
- generated opportunity briefs
- feedback
- authoring drafts
- operational state
For this application, I use SQLite for that internal store.
There is also a second database involved, but it serves a completely different purpose.
My personal website has its own Postgres database containing projects, work, labs, and writing. SpiderMind periodically reads selected data from that database so its internal context can stay aligned with what is currently published on the site.
SpiderMind does not use that database as its primary application database.
The separation is intentional:
SpiderMind SQLite
→ operational and contextual state owned by SpiderMind
Website Postgres
→ external source of current portfolio/content information
The website connection is read-only.
This lets the two systems evolve independently without turning my website database into SpiderMind's persistence layer.
The first deployment problem: local files are not persistent
Locally, SQLite is straightforward.
The application can simply use something like:
data/spidermind.db
and everything works.
That assumption changes when the application is deployed.
A service filesystem should not be treated as durable application state unless persistent storage is explicitly attached. For a SQLite-backed application, that distinction matters immediately.
If the database lives only inside the deployment filesystem, a redeployment can replace the filesystem containing the database.
So the first production rule became:
Anything SpiderMind needs to preserve must live on persistent storage, not on the normal deployment filesystem.
Adding a Railway Volume
Railway Volumes provide persistent storage that can be mounted into a service.
For SpiderMind, I mount persistent storage under /data and keep application-owned files inside that boundary.
Conceptually:
/data
├── spidermind.db
└── authoring_assets/
The application does not hardcode its local development path.
Instead, the SQLite database path is configurable through an environment variable. In production, that can point to a database file inside the mounted volume:
SPIDERMIND_DB_PATH=/data/spidermind.db
The code resolves that value when the application starts.
In simplified form:
def get_db_path():
override = os.getenv("SPIDERMIND_DB_PATH", "").strip()
if override:
return Path(override).resolve()
return project_root / "data" / "spidermind.db"
That gives me two clean environments.
Locally:
project/data/spidermind.db
Production:
/data/spidermind.db
No deployment-specific path needs to be baked into the application.
I use the same pattern for generated authoring assets:
AUTHORING_ASSETS_DIR=/data/authoring_assets
The general rule is simple:
If a file must survive a redeploy, its path needs to resolve inside persistent storage.
My Railway deployment configuration
The backend is a FastAPI application.
The current Railway configuration uses Nixpacks and starts Uvicorn explicitly:
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "NIXPACKS"
},
"deploy": {
"startCommand": "PYTHONPATH=src python -m uvicorn spidermind.api.app:app --host 0.0.0.0 --port $PORT",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 3
}
}
Two parts matter here.
--host 0.0.0.0
makes the application listen outside the container.
And:
--port $PORT
lets Railway provide the runtime port instead of hardcoding one.
Keep deployment configuration outside the code
Most production-specific behavior is controlled through environment variables rather than Python changes.
For SpiderMind, the important categories are roughly:
Application secrets
LLM/API credentials
SQLite persistence path
Asset storage path
Authentication configuration
Allowed CORS origins
External website database connection
Scheduler feature flags
I keep defaults suitable for local development and override them in Railway.
For example:
SPIDERMIND_DB_PATH=/data/spidermind.db
AUTHORING_ASSETS_DIR=/data/authoring_assets
SPIDERMIND_AUTH_ENABLED=true
CORS_ORIGINS=https://your-frontend-domain.com
Secrets stay in the deployment environment and never go into the repository.
A second database does not have to mean a second application database
One part of this architecture that was worth making explicit is the external website database.
SpiderMind has its own SQLite database.
Separately, my website uses Postgres.
SpiderMind can connect to that Postgres instance through a separate connection string:
WEBSITE_DATABASE_URL=...
But that connection has a very limited responsibility.
SpiderMind reads selected website data such as:
projects
work
labs
writing
and normalizes that information into its own memory model.
The direction is:
Website Postgres
│
│ read
▼
SpiderMind ingestion
│
▼
SpiderMind SQLite
Not:
SpiderMind ↔ shared application database
And not:
SpiderMind writes back into website Postgres
The adapter is intentionally read-only.
That boundary matters because the website remains the source of truth for published portfolio information, while SpiderMind remains the owner of its own context and operational history.
Keeping the website context fresh
The website synchronization can be triggered manually, but SpiderMind also supports scheduled refreshes.
The system can periodically read the website database and ingest relevant changes into SpiderMind's memory.
For example:
new project on website
↓
scheduled website refresh
↓
read website Postgres
↓
normalize project data
↓
update SpiderMind context
This is useful because I do not want to maintain the same project information manually in two systems.
It also illustrates why SpiderMind still owns its own database.
The website is an input source, not the runtime state store.
Connecting the Next.js frontend
The frontend runs separately on Vercel.
It only needs to know where the Railway backend is:
NEXT_PUBLIC_API_URL=https://your-backend.railway.app
The FastAPI backend then allows the frontend origin through CORS:
CORS_ORIGINS=https://your-frontend.vercel.app
So the runtime path becomes:
Browser
↓
Next.js / Vercel
↓
FastAPI / Railway
↓
SpiderMind persistence + external sources
I like this split for personal projects because each platform is doing something it is comfortable doing:
- Vercel serves the Next.js frontend
- Railway runs the stateful Python service
- the Railway Volume preserves application-owned files
Add a health endpoint before debugging everything else
SpiderMind exposes:
GET /api/health
This sounds trivial, but it makes deployment debugging much easier.
Before touching frontend configuration, CORS, authentication, or scheduled jobs, I first want to know:
Is the backend actually alive?
My deployment order is roughly:
1. Deploy backend
2. Verify /api/health
3. Verify persistent paths
4. Configure authentication
5. Connect the frontend
6. Verify CORS
7. Test real application flows
8. Only then enable scheduled work
That last step is important.
I do not want a broken deployment automatically running background jobs just because the process successfully started.
Do not enable background jobs on day one
SpiderMind performs recurring collection and synchronization work.
It would be easy to deploy the API and immediately enable every scheduler.
I deliberately avoid that.
A background process can create:
- API usage
- external service costs
- duplicated work
- bad persisted state
- harder-to-debug failures
before I have even confirmed that the application works correctly in production.
So I treat scheduling as a later deployment gate.
First:
API works
persistence works
authentication works
frontend works
manual workflow works
Then:
scheduler enabled
The project followed the same pattern in production: deploy the protected and observable system first, validate it with live runs, then enable scheduled scans.
Test persistence explicitly
A successful HTTP request does not prove that your persistence setup works.
For a SQLite + Volume deployment, I want to test the exact failure mode I care about.
A simple persistence test looks like this:
1. Create or update application state
2. Confirm it exists
3. Trigger a redeployment
4. Wait for the new deployment
5. Query the same state again
If the state disappears after step 4, the application was not writing to the persistent storage you expected.
This is the kind of issue that local development will never expose.
What I learned from this setup
1. Treat deployment storage as ephemeral unless you explicitly made it persistent
Do not rely on a local file path simply because it works inside the running service.
Ask:
Should this file still exist after this deployment is replaced?
If yes, it belongs on persistent storage.
2. SQLite and deployment are not mutually exclusive
SQLite is enough for SpiderMind's current single-owner architecture.
The important part was not replacing SQLite with Postgres just because the application moved to the cloud.
The important part was understanding its persistence requirements.
That decision would obviously be different for another workload.
3. Separate application state from external sources
SpiderMind owns its SQLite state.
My website owns its Postgres data.
SpiderMind reads website data and converts it into its own context.
That boundary keeps the architecture easier to reason about.
4. Make filesystem paths configurable
Local and production storage paths should not require separate code branches.
Environment-based path overrides are simple and effective.
5. Deployment success is not application success
A green deployment does not prove:
- persistence works
- CORS works
- auth works
- background jobs are safe
- an external database is reachable
Test those boundaries independently.
When I would use this setup again
I would consider this architecture again for:
- personal AI tools
- internal utilities
- single-owner applications
- small FastAPI services with local state
- prototypes that have moved beyond completely disposable storage
- tools that generate persistent files or artifacts
I would reconsider it if I needed things like:
- multiple application replicas writing to the same SQLite database
- large concurrent write workloads
- independent database scaling
- more complex availability requirements
At that point, moving the primary state to a managed database would likely make more sense.
For SpiderMind today, the simpler architecture is still the right tradeoff.
Try Railway
I currently use Railway to run SpiderMind's FastAPI backend and persistent application storage.
If you are deploying a similar Python side project and want to try Railway, you can use my referral link:
Disclosure: this is an affiliate link. You may receive Railway's current referral benefit, and I may earn a commission if you become a paying customer, at no additional cost to you.
The bigger lesson for me was not really about Railway.
It was this:
A side project starts becoming a real system when deployment forces you to decide what owns state, what is disposable, and what must survive.
SpiderMind still has a relatively small infrastructure footprint.
But explicitly separating its application state, persistent files, frontend, and external data sources made the system much easier to deploy and reason about.