Running many side projects on one VPS sounds simple until the server starts looking like a small city.
One app needs Postgres. Another needs Redis. One wants port 3000. Another also wants port 3000. A third one works locally but becomes mysterious behind a domain. Then you add SSL, Docker, Caddy, auth, logs, backups, and suddenly the “cheap VPS” has become a tiny production platform.
That is basically where I ended up.
I had my portfolio, ZenRay, Magio, Gatehouse, a disease dashboard, a personal data vault, and some shared infrastructure all running on the same Hostinger VPS. None of them were huge individually, but together they forced me to think properly about deployment.
The First Rule
Every project needs a clear port.
That sounds obvious, but it is the first thing that saves your sanity. I keep a simple port map for the server so I know which service owns what.
portfolio 4100
finsight frontend 4111
finsight api 4112
magio 4121
gatehouse auth 4131
personal vault 4141
disease dashboard 4201
zenray web 4001
zenray dashboard 4002
zenray api 4003
It is not fancy. It is not a platform. It is just a source of truth.
Without that, you eventually run docker compose up and something silently fails because another app already owns the port. Then you waste twenty minutes debugging the wrong thing.
Docker Is The Boundary
I like keeping each app in its own Docker Compose setup. The app owns its Dockerfile, env file, port mapping, and restart policy. The server only knows how to run it.
For most projects, the deploy shape is boring:
git pull
docker compose up -d --build
That boring shape is good. If every project deploys differently, the server becomes hard to reason about. If every project has the same basic lifecycle, debugging gets much easier.
The important part is to not over engineer too early. I do not need Kubernetes for a personal VPS. I need containers that restart, logs I can inspect, and configs I can understand at 3 am.
Caddy Does The Public Work
The apps do not need to know about HTTPS. They do not need to manage certificates. They do not need to bind to port 80 or 443.
Caddy handles that.
Each public domain points to a local port:
deepaksilaych.me -> localhost:4100
magio.deepaksilaych.me -> localhost:4121
auth.deepaksilaych.me -> localhost:4131
data.deepaksilaych.me -> localhost:4141
disease.deepaksilaych.me -> localhost:4201
This keeps the apps simple. They listen on internal ports, and Caddy becomes the front door.
It also makes SSL boring, which is exactly how SSL should feel.
Shared Infrastructure Needs Discipline
Some services should not be duplicated for every project. Postgres is the best example. If every app starts its own database container, the server gets noisy fast.
So I keep shared infrastructure separately. One shared Postgres. One shared Redis. One shared network. Apps that need them connect through Docker networking.
That sounds small, but it changes how clean the setup feels.
Magio can use the shared Postgres without owning database deployment. ZenRay can use shared Redis and object storage without stuffing those services into its own app folder. The app remains the app. Infrastructure remains infrastructure.
This is the line I try to keep:
app repo application code and app compose
infra folder shared databases, queues, storage
Caddyfile domains and reverse proxy rules
ports file port ownership
Once that separation exists, replacing one part becomes less scary.
Auth Should Not Be Rewritten In Every App
Some projects are public. Some are private. Some are half public and half internal.
At first, the temptation is to add login to every app. That gets annoying quickly. Every app needs users, sessions, redirects, cookies, logout, and protection rules. Suddenly the side project is not about the original idea anymore. It is about auth plumbing.
That is why I built Gatehouse.
Gatehouse sits behind Caddy as a forward auth service. Caddy asks Gatehouse if a request should pass. Gatehouse checks the session, domain rules, public paths, and then either allows the request or redirects to login.
The nice part is that the protected app does not need to know any of this. My personal vault can stay simple. Magio can stay focused on email tracking. The auth layer lives in one place.
The Bugs Teach You The System
The server only became understandable after things broke.
ZenRay went down because containers had exited. OpenVPN worked on mobile data but not university WiFi because UDP was blocked. Magio had a Docker build issue because the Dockerfile copied a public directory that did not exist. Gatehouse had a route bug because /api was duplicated. The vault looked public even after auth because one config used a full URL instead of just the domain.
None of these were glamorous problems.
But each one made the setup cleaner.
After enough of them, you stop thinking of deployment as a final step. Deployment is part of the product. If the app cannot be restarted, inspected, routed, protected, and recovered, it is not really shipped.
What I Would Keep
If I had to rebuild the server from scratch, I would keep the same simple pieces:
- Docker Compose per app
- one Caddyfile for public routing
- one shared infra folder
- one port map
- one auth gateway for private tools
- env files kept on the server
- no app binding directly to 80 or 443
That setup is not enterprise grade, but it is very productive. It lets me ship small projects without turning every deployment into a cloud architecture exercise.
And honestly, that is the point.
The VPS is not just a machine. It is a little playground, a staging server, a production box, and a notebook of mistakes. Every side project teaches something, but only if deploying it is easy enough that you actually do it.