Our getting-started guide takes you from purchase to a working API on your laptop, and its deployment step is six bullets long. That was honest as an outline and useless as instructions, which is the gap this guide fills. Getting a FastAPI app running on a server is easy — you SSH in, activate the venv, run uvicorn, and it works. It stops working the moment you close your terminal, and again when the server reboots, and again the first time an unhandled exception takes the process down at 3am.
The difference between a process and a service is four artifacts. This guide writes all four: a systemd unit that survives reboots and restarts on failure, an nginx reverse proxy terminating SSL with Let's Encrypt, a health endpoint worth gating on, and a CI/CD job that deploys and then verifies rather than assuming. It also covers the one step you should deliberately not automate.
Read This Before You Write Your Pipeline
ShipKit ships no migration tooling. There is no Alembic in the box, no migrations directory, and no upgrade command. The SQLAlchemy models are defined; evolving that schema safely once real data exists is entirely your responsibility.
This matters here specifically because a deploy pipeline wants a “run migrations” step, and it is very easy to write alembic upgrade head into a CI job out of habit and watch it fail on a server that has never heard of Alembic. Nothing in this guide runs migrations, and that omission is deliberate rather than an oversight. If you want the craft on its own terms, our guide to zero-downtime migrations with Alembic is vendor-neutral and applies cleanly here — you would be adding that layer yourself, on purpose, as its own decision.
What You're Starting From
This assumes a working ShipKit project locally: FastAPI application structure, JWT authentication, Stripe Checkout, an admin foundation, and MySQL models via SQLAlchemy. All of that is in both tiers, Starter at $29 and Professional at $79, one-time.
One tier difference is directly relevant. VPS deploy scripts and CI/CD pipeline templates are Professional-only. If you have Professional, the artifacts below will look familiar and you should treat this as an explanation of what they are doing and why. If you have Starter, this guide is how you write them yourself, and everything here is the whole job rather than an excerpt.
On the server, you need Python 3.10 or newer, MySQL, and nginx. MySQL, not Postgres — ShipKit's models are written against MySQL, and quietly substituting Postgres is a migration project rather than a config change.
Artifact 1: The systemd Unit
This is the piece that converts “a program I ran” into “a service the machine is responsible for.” Put it at /etc/systemd/system/myapp.service:
[Unit]
Description=MyApp API
# Do not start before the network and database are up. Without this,
# a reboot races the app against MySQL and the app usually loses.
After=network.target mysql.service
Wants=mysql.service
[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/myapp/server
ExecStart=/opt/myapp/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 4
# The restart policy is the entire point of this file.
Restart=always
RestartSec=5
# Give in-flight requests time to finish before the process dies.
TimeoutStopSec=35
KillSignal=SIGTERM
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
Four lines in there do the real work, and they are the ones most often left out.
After / Wants
On reboot, everything starts at once. Without these, your app can come up before MySQL is accepting connections, fail its first queries, and either crash or sit there broken. Wants asks for MySQL, After orders the two.
Restart=always with RestartSec
Restart=always is what makes a 3am crash a five-second blip instead of a morning of downtime. RestartSec=5 is the guard against a crash loop hammering your database. Without a delay, a process that dies instantly on startup will do so hundreds of times a minute.
host 127.0.0.1, not 0.0.0.0
Bind to localhost. nginx sits in front, and it is the only thing that should be able to reach uvicorn. Binding to 0.0.0.0 publishes your app on port 8000 to the entire internet, bypassing every rule you are about to write in nginx.
User=myapp
Not root. If the app is ever compromised, this line is the difference between an attacker owning one directory and owning the machine. Create the user, chown the app directory to it, and never think about it again.
Then enable it, which is the step that makes it survive reboots:
sudo systemctl daemon-reload
sudo systemctl enable myapp # start on boot
sudo systemctl start myapp
systemctl status myapp
enable and start are different verbs. Starting without enabling gives you a service that works perfectly until the next reboot, which is a genuinely painful way to learn the distinction.
Artifact 2: nginx and SSL
nginx handles TLS, serves as the public entry point, and forwards to uvicorn on localhost. Put this at /etc/nginx/sites-available/myapp:
server {
server_name api.example.com;
# ShipKit's Stripe Checkout and admin uploads both need room;
# nginx defaults to 1M and returns 413 without touching your app.
client_max_body_size 10M;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
# Without these, every client looks like 127.0.0.1 to your app,
# which breaks rate limiting and logging in ways that are
# confusing to debug later.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 90;
}
listen 80;
}
Enable the site and check the syntax before reloading, because a bad config plus a reload takes the site down:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t # always. every time.
sudo systemctl reload nginx
Now SSL. Certbot will rewrite the config above to add the certificate lines and an HTTP-to-HTTPS redirect:
sudo certbot --nginx -d api.example.com
Certbot installs a renewal timer automatically. Confirm it rather than trusting it, because Let's Encrypt certificates last ninety days and a silent renewal failure is the classic way to take an API down on a quiet Sunday:
sudo certbot renew --dry-run
systemctl list-timers | grep certbot
Artifact 3: A Health Endpoint Worth Gating On
Nearly every health endpoint in the wild is this:
@app.get("/health")
async def health():
return {"status": "ok"}
Which answers exactly one question: is the web process running? That is not the outage you are worried about. An app whose database connection has died will answer that endpoint cheerfully and fail every real request. Check the dependency:
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from sqlalchemy import text
from .db import get_db # whatever ShipKit names yours
router = APIRouter()
@router.get("/health")
async def health(db = Depends(get_db)):
checks = {}
try:
db.execute(text("SELECT 1"))
checks["database"] = "ok"
except Exception:
checks["database"] = "error"
healthy = all(v == "ok" for v in checks.values())
return JSONResponse(
# The status code is the part that matters. A pipeline reads
# the code, not your JSON, so an unhealthy app must not
# answer 200 or every automated check will believe it.
status_code=200 if healthy else 503,
content={"status": "healthy" if healthy else "degraded", "checks": checks},
)
Keep it cheap and unauthenticated. It gets hit constantly by your pipeline and any uptime monitor, so it should not do real work, and it should not leak anything — version strings and dependency details in a public health response are free reconnaissance. If a check is expensive, cache it for a few seconds rather than skipping it.
Artifact 4: The CI/CD Job
The pipeline's job is not to copy files. It is to copy files and then prove the result works. A deploy job that reports success because scp exited zero is theatre.
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Run the tests here, before anything reaches the server.
# A pipeline that deploys first and tests after is just a
# slower way to ship a broken build.
- name: Test
run: |
pip install -r server/requirements.txt
pytest
- name: Upload
run: |
rsync -az --delete \
--exclude '.env' \
--exclude '__pycache__' \
server/ deploy@$HOST:/opt/myapp/server/
- name: Install deps and restart
run: |
ssh deploy@$HOST '
/opt/myapp/venv/bin/pip install -q -r /opt/myapp/server/requirements.txt &&
sudo systemctl restart myapp
'
# The step that turns a file copy into a deploy.
- name: Verify
run: |
for i in $(seq 1 10); do
code=$(curl -s -o /dev/null -w '%{http_code}' https://api.example.com/health)
if [ "$code" = "200" ]; then echo "healthy"; exit 0; fi
sleep 3
done
echo "health check failed after restart"
exit 1
Three details in there are load-bearing. --exclude '.env' keeps your repository from overwriting production credentials with development ones, which is a bad afternoon. The retry loop exists because the app takes a few seconds to come up and a single immediate curl will fail on a perfectly good deploy. And the verify step fails the job when health does not return 200, so a broken deploy shows up as a red build instead of a silent outage.
What This Pipeline Deliberately Does Not Do
There is no migration step, because ShipKit has no migration tooling to invoke. If you add a schema layer yourself, resist the reflex to bolt it onto this job as one more line: schema changes and code deploys have different failure modes and different rollback stories, and running them as a single unit is how a bad migration takes an app down with no clean way back. Ship them as two steps, migration first, exactly as our Alembic guide lays out.
Verifying It Actually Survives
Everything above can be true and still leave you with a service that dies on reboot. Three checks, in order, take two minutes:
- Kill the process.
sudo systemctl kill -s SIGKILL myapp, wait five seconds, then hit your health endpoint. If it answers,Restart=alwaysworks. - Reboot the box.
sudo reboot, wait, then hit health again. If it answers,enableworked and yourAfter=ordering is right. - Read the logs.
journalctl -u myapp -n 50. Look for tracebacks on startup that the app recovered from. Those are the ones that bite later.
Do all three now, on purpose, while you are watching. The alternative is finding out during an unplanned reboot.
The Bottom Line
Deployment is not the hard part of shipping a backend, but it is the part where the failures are least forgiving, because they happen when nobody is looking. The four artifacts here are small — a unit file, a server block, one endpoint, one job — and between them they cover reboots, crashes, certificate expiry, and broken deploys. Professional ships templates for the first, second and fourth; Starter means writing them once and copying them into every project after.
Either way, the shape is the same, and so is the sequence: make it run, make it restart, make it reachable over TLS, then make the pipeline tell you the truth about whether the last deploy actually worked. And leave the schema alone until you have decided, deliberately and separately, how you intend to handle it.
Own Your Backend
ShipKit is a production-ready FastAPI foundation you buy once and keep — JWT auth, Stripe Checkout, an admin foundation, and MySQL models. Professional adds the deploy scripts and CI/CD templates described above.
Explore ShipKit