Deploying with Coolify on your own server, without lock-in
How we deploy this site on a VPS with Coolify, Docker and Cloudflare: CI decides what reaches production and nothing depends on a single vendor.
Managed hosting platforms are convenient until they change the price, the rules or the limit you care about. For this site we wanted the opposite: a server of our own, built from parts that can be swapped one at a time. Here is how it’s put together and why.
The parts
| Part | What it does | If it has to change tomorrow |
|---|---|---|
| VPS | Runs the containers | Any provider with Docker |
| Coolify | Builds and deploys the containers, with its Traefik proxy | It’s open source; the site doesn’t need it to run |
| Docker | Packages the site: the same image locally, in CI and in production | Standard |
| Cloudflare | DNS, protection and caching in front of the server | The site works without it; you lose the cache |
| GitHub Actions | Tests every change and decides whether it ships | The workflow is a file in the repository |
The underlying rule: everything that defines the site lives in the repository. The Dockerfile, the server and its headers, the tests and the deployment workflow. If the server disappears, the site is rebuilt on another one.
A server just for this
The site runs on a dedicated VPS, separate from any personal or experimental project. The reason isn’t capacity, it’s the blast radius: a site that prides itself on security shouldn’t share a server with experiments. If one of those is compromised, it doesn’t take the business with it.
The server only accepts web traffic that comes through Cloudflare, over IPv4 and IPv6, and we verify that from the outside, not by reading the configuration. Coolify’s dashboard isn’t exposed to the internet.
The image
The site is built in a multi-stage Docker image: the first stage installs dependencies and generates the site; the final one keeps only what’s needed to serve it, with an unprivileged user and a health check that Coolify polls. With docker compose up we run locally exactly what runs in production, plus a test mailbox for the contact form.
CI decides what ships
The repository is private and the account is free, so GitHub doesn’t allow protecting the main branch. With Coolify’s automatic deploys, a push with failing tests would reach production. We solved it like this:
- In Coolify, automatic deployment is off.
- When a change is merged, CI runs lint, types, build, unit tests, browser tests with axe, and Lighthouse.
- Only if everything passes, a second workflow calls Coolify’s API to deploy that exact commit and, when done, purges Cloudflare’s cache.
If CI fails, nothing ships, and the failure is visible. Paid branch protection would have solved the same thing; this costs nothing and uses something we already had.
Caching does the heavy lifting
The server is far from most of our visitors, and that distance shows on every request that reaches it. Cloudflare doesn’t cache HTML by default, so we added an explicit rule to do it and purge the cache on every deploy. That way, almost every visit is served from the nearest Cloudflare node. You can see it in Lighthouse: mobile LCP on the home page is around 1.5 seconds.
Backups away from the provider
Databases (today, the visit counter’s) are backed up daily to S3-compatible storage at a different provider. A backup on the same server is useless if that server disappears. And a backup that has never been restored isn’t really a backup: testing a restore is on our pre-launch checklist.
What it costs
None of this is free in attention. A server of your own needs updates, monitoring and, now and then, reading a log at an odd hour. In exchange, you know exactly what runs, where and why, and you can move it whenever you want. For a studio that sells custom software, that transparency is part of the product too.
Want something like this for your application? Tell us about your project.