The usual way a solo founder finds out their deployment process is broken is a bad Friday. You SSH into the VPS, run git pull, restart the service, and something goes wrong: a migration half-applies, the Node process dies because the box ran out of memory during the build, or you realize the change never went live because the old process was still bound to the port. Your beta users notice before you do.
This guide walks through how to deploy an application stack (frontend, API, database, and file storage) so that every push to your main branch produces a tested, reproducible release. No Kubernetes cluster, no Helm charts, and no SSH sessions. The example uses a Next.js frontend with a FastAPI backend, but the same steps apply to Laravel, Rails, Django, or Express.
What "deploy application" actually covers
When people say "deploy," they often mean "copy code to a server." A reliable deployment is a longer chain, and failures happen at every link:
- Build: turn source code into a runnable artifact (a container image, a compiled bundle).
- Configure: inject environment-specific settings such as database URLs, API keys, and feature flags.
- Release: apply database migrations and swap the new version in for the old one.
- Run: keep the process alive, restart it on crashes, route traffic to it, and scale it.
- Observe: know whether the new version is healthy, and roll back if it is not.
A manual VPS workflow usually does steps 1 to 4 on the same machine that serves production traffic, by hand, in a slightly different order each time. That is where the "did it actually go live?" anxiety comes from. The fix is not more discipline. The fix is to make each step automatic and identical on every release.
Why Kubernetes is the wrong default for a one-person team
Kubernetes solves the "run" step extremely well: scheduling, self-healing, rolling updates, service discovery. It is the right tool for organizations running dozens of services with a platform team to maintain it.
For a solo developer, the cost profile is different:
- You become responsible for the cluster itself: node upgrades, ingress controllers, certificate management, storage classes, and RBAC.
- A minimal production setup needs manifests for a
Deployment,Service,Ingress,Secret, and often aHorizontalPodAutoscaler, plus a way to template them per environment. - Managed Kubernetes removes the control plane burden but not the configuration burden, and you still pay for worker nodes whether or not they are busy.
Kubernetes is an answer to "how do we run many services across many machines." If your question is "how do I ship my SaaS without breaking it," you need a build pipeline and a managed runtime, not a cluster.
What you actually need is the outcome of an orchestrator (automatic restarts, rolling releases, horizontal scaling) delivered as a service. The rest of this guide sets up exactly that.
Step 1: make the app deployable
Before any platform can deploy your app reliably, the app has to follow a few rules. These come straight from the Twelve-Factor App methodology and they matter more than any tool choice.
Read all configuration from environment variables
Nothing environment-specific should be committed to the repo. In FastAPI, pydantic-settings makes this clean:
# app/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
s3_endpoint: str
s3_bucket: str
s3_access_key: str
s3_secret_key: str
allowed_origins: list[str] = ["http://localhost:3000"]
settings = Settings()If a required variable is missing, the app fails at startup with a clear error instead of failing at 2 AM on the first request that touches the database.
Keep processes stateless
Anything written to the local filesystem can disappear on the next deploy or when a second instance starts. User uploads go to object storage, sessions go to the database or a cache, and logs go to stdout.
Expose a health check
The platform needs a way to know a new release is ready before sending it traffic. Make the check meaningful by touching the database:
# app/main.py
from fastapi import FastAPI, Response
from sqlalchemy import text
from app.db import engine
app = FastAPI()
@app.get("/healthz")
def healthz(response: Response):
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return {"status": "ok"}
except Exception:
response.status_code = 503
return {"status": "db_unreachable"}Step 2: containerize each service
A container image is the most portable build artifact you can produce. The same image runs on your laptop, in CI, and in production, which eliminates the "works on my machine" class of failure.
The API
# api/Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]Two details worth copying: PYTHONUNBUFFERED=1 makes logs appear immediately instead of being buffered, and the non-root user limits the damage if a dependency is ever compromised.
The frontend
For Next.js, enable standalone output so the production image only contains what it needs:
// next.config.js
module.exports = {
output: "standalone",
};# web/Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]The multi-stage build keeps dev dependencies and build caches out of the final image, which usually cuts it from over a gigabyte to a couple of hundred megabytes. Smaller images mean faster deploys and faster scale-ups.
Test both images locally with docker compose up --build before going further. If they run on your machine with environment variables alone, they will run on a managed platform.
Step 3: treat migrations as a release step
Most botched releases on small teams are migration problems, not code problems. Two practices prevent almost all of them.
Run migrations automatically, once per release, before the new code takes traffic. With Alembic, that is a single command:
alembic upgrade headWire it into the release process rather than running it by hand. On a platform that lets you set a start command, a small entrypoint script works:
#!/bin/sh
# api/entrypoint.sh
set -e
alembic upgrade head
exec uvicorn app.main:app --host 0.0.0.0 --port 8000This is fine while you run a single API instance. Once you scale to several, make sure only one instance runs migrations (a separate release job or an advisory lock), or they will race each other.
Use the expand and contract pattern for breaking schema changes. Never rename or drop a column in the same release that stops using it. Instead:
- Release A adds the new column and writes to both old and new.
- Release B backfills data and switches reads to the new column.
- Release C drops the old column.
Each release is safe to roll back, because the previous version of the code still finds the columns it expects.
Step 4: gate deploys with CI
Automatic deploys on every push are only safe if broken code cannot reach the main branch. A minimal GitHub Actions workflow runs your tests on every pull request:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
api:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install -r api/requirements.txt
- run: cd api && alembic upgrade head && pytest
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
- run: cd web && npm ci && npm run lint && npm run buildThen turn on branch protection for main in your repository settings and require both jobs to pass before merging. GitLab and Bitbucket have equivalent pipeline and merge-check features. From this point, "merged to main" means "tested," and the deploy trigger can safely fire on every push.
Note that the API job runs the migrations against a fresh database before the tests. That catches a broken migration in CI instead of in production.
Step 5: connect the repo to a managed platform
With deployable images and a CI gate, the remaining work is the runtime: something that builds on push, runs the containers, restarts them on failure, handles TLS, and gives you a database you do not have to babysit.
On Granite.so, the setup looks like this:
- Connect your Git repository (GitHub, GitLab, or Bitbucket) and create a service for each deployable directory: one for
api/, one forweb/. Each service builds from its Dockerfile. If you already push images to a registry, you can deploy from a Docker image instead. - Set environment variables per service in the dashboard. Secrets live in the platform, not in the repository.
- Attach a managed database. The connection string is provided to the API service as an environment variable, and backups are handled by the platform, so there is no
cronjob runningpg_dumpto a disk that might fill up. - Create an S3-compatible storage bucket for user uploads. It is served through a CDN, so images and files load quickly without a separate CDN account.
- Turn on automatic deploys for
main. Every merge triggers a rebuild and a release. Automatic scaling adds capacity when traffic spikes, without you writing an autoscaler config.
Because the storage is S3-compatible, your upload code uses the standard SDK with a custom endpoint:
import boto3
from app.config import settings
s3 = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
)
def upload_avatar(user_id: int, data: bytes, content_type: str) -> str:
key = f"avatars/{user_id}.webp"
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
return keyThe Hobby plan is $9 per month and includes $15 of monthly usage credits, which covers a typical early-stage SaaS with a frontend, an API, a small database, and a storage bucket. Check the pricing page for the current plan details before you commit.
Step 6: verify every release
Automation does not remove the need to check. It makes checking cheap enough to do every time. Add a smoke test you can run after each deploy, or better, schedule it from CI after merges to main:
#!/bin/sh
# scripts/smoke.sh
set -e
API_URL="${API_URL:-https://api.example.com}"
WEB_URL="${WEB_URL:-https://app.example.com}"
curl --fail --silent --show-error "$API_URL/healthz"
curl --fail --silent --show-error --output /dev/null "$WEB_URL/"
echo "Smoke test passed"A useful habit is exposing the Git commit in a /version endpoint, read from an environment variable set at build time. It answers the question "did my push actually go live?" in one request instead of an anxious hour of guessing.
Plan your rollback before you need it
Know in advance how you will undo a bad release:
- Code rollback: revert the commit and push. The pipeline builds and deploys the previous version. This only works cleanly if your migrations follow the expand and contract pattern.
- Data rollback: know how to restore from the managed database's backups, and test it once on a copy before launch day. A backup you have never restored is a hope, not a backup.
Honest trade-offs
A managed platform is not the right answer for everyone. Consider staying on a VPS or moving to Kubernetes if:
- You need low-level control over the host: custom kernel modules, GPU drivers, or unusual networking.
- You run many services with complex inter-service traffic and have someone whose job is infrastructure.
- Your workload is large and steady enough that raw compute pricing matters more than the hours you spend on operations.
For most SaaS products before product-market fit, none of these apply. The scarce resource is your time, and every hour spent patching a server or debugging an ingress controller is an hour not spent on the product.
Your deployment checklist
- All configuration comes from environment variables
- Processes are stateless; uploads go to object storage
/healthzchecks the database- Each service has a Dockerfile that runs locally with
docker compose up --build - Migrations run automatically once per release, using expand and contract for breaking changes
- CI runs tests and migrations on every pull request, and
mainis protected - Repo connected to the platform with auto-deploy on push to
main - Managed database attached, with a tested restore
- Smoke test and
/versionendpoint confirm each release
Ship features, not servers
The goal of a good deployment setup is that you stop thinking about it. When a merge to main reliably turns into a tested, running release, with a database someone else keeps healthy and backed up, you get your evenings back.
If you want that setup without assembling it from separate vendors, Granite.so lets you deploy application code, a managed database, and CDN-backed storage from one Git repository, starting at $9 per month on the Hobby plan. Connect your repo, set your environment variables, and let the next push be the last one you worry about.