Thapa Technical — Dev Blog
Claude Code VPS Deployment Coolify Next.js + Postgres

I Deployed a Full-Stack App to a VPS
Without Touching the Server: the whole conversation.

Ten prompts. One API token. Two DNS records. Zero SSH sessions. This is the story of moving a Next.js + Postgres app from localhost:3000 to a live HTTPS domain on a VPS running Coolify — with Claude Code doing the typing, and me doing the thinking.

🚀
💡 Special Reader Offer

Don't have a VPS yet? Start your project today!

To follow this guide and deploy your own app with Coolify, you will need a VPS. Grab our community-exclusive deal to get the best price:

🔗 Exclusive Buy Link: hostinger.com/in/thapa7
🎟️ Coupon Code:
THAPA7 (Extra 10% OFF!)

The cast

Me — a developer with a working app, a VPS I half understand, and a deep reluctance to open a terminal at 11pm.

Claude Code — an AI agent with access to my repo, my shell, and (once I hand it a token) my server.

The interesting part of this story is not that the AI deployed the app. It is the four or five moments where I had to be the adult in the room. Watch for those.

🔒 A note on the placeholders. Everything below really happened, but I have replaced my real server IP, domain and project names with placeholders like <VPS_IP>, jobs.yourdomain.com and <YOUR_IP>. Swap in your own.

What you need before you start

Thing Why
A VPS (any provider) The box. Mine runs Ubuntu 24.04 with 2 vCPU / 8 GB RAM.
Coolify installed on it The self-hosted “Heroku”. Gives you a UI and a REST API for deploys.
A Coolify API token So the AI can create projects, databases and apps without you clicking. Coolify UI → Keys & Tokens → API tokens.
A GitHub repo Coolify deploys from git. Private repos are fine.
A domain you control DNS for For the subdomain and the free HTTPS certificate.
(Optional) Hostinger MCP Lets the AI read your VPS details, firewall rules and DNS directly. Any provider works — MCP just removes the copy-paste.
⚠️ That API token is the keys to your castle. It can create and destroy infrastructure. Give it, use it, and revoke it when you are done. That is the last scene of this story, and it is not optional.
Scene 1

The prompt that decides everything

It is late. The app works locally. Postgres is humming away on my laptop, a cron job pulls new listings every morning. And in every way that matters, it is not a real product — because it only exists on a machine I close at night.

Me

The app works locally. Now let's host it on my VPS using Coolify.
Look at the project first and tell me what you need from me.

That second sentence is the entire post. I could stop here and you would have gotten your money's worth.

“Deploy my app” is a wish. “Look first and tell me what you need” is an instruction to audit before acting — and that is the difference between an agent that helps you and an agent that confidently bulldozes something.

Claude Code

Reading the project... Next.js 15, Postgres 16, Drizzle ORM, a couple of cron scripts under /scripts. For production this needs a Postgres instance, three environment variables, a build step, and somewhere for the daily pipeline to run.

Before I touch anything — I checked your VPS through the Hostinger MCP. It already has a live site on it. Coolify is running, Traefik is holding ports 80 and 443, and there is another website of yours with its own database container.

I am not going to touch those containers. Everything for the new site will be additive. Confirm?

I sat back a bit at that.

I knew there was another site on that box. I just had not thought to mention it, because at 11pm you do not think, you type. If I had said “deploy my app” and walked away to make tea, an agent with less discipline could have restarted the proxy, grabbed port 443, and taken down a live site to make room for my side project.

🧠 The biggest lesson here: never let an agent loose on a server that already has something running without saying so. Better still — as I did by accident — make it tell you.

Steal this prompt:

Prompt — audit first
Before you deploy anything, audit the server: what's already running on it, what ports
are taken, what would break if you added a new app? Tell me, then wait for my go-ahead.
Scene 2

Thirty seconds of questions that saved me hours

Claude Code

Three decisions before I start. I have a recommendation for each.

  • Domain. A subdomain like jobs.yourdomain.com, or a brand new domain?
  • Subdomain or a /jobs path on your main site?
  • Database. A new Postgres 16 resource inside Coolify, internal-only, with no public port?
Me

I was going to buy a keyword-rich domain. Better for SEO, right?

Claude Code

No. The “exact match domain” ranking boost is a myth that has been dead since roughly 2012 — Google specifically devalued it. You would be paying for a domain, splitting your authority across two properties, and starting a brand from zero. A subdomain inherits everything your main domain already has.

On the /jobs path: not possible here anyway. Your main domain lives on a different hosting account. You cannot proxy a path across two accounts you do not control the routing between.

Database: internal-only. Nothing about this app requires the database to be reachable from the internet, so it should not be.

Three questions. Thirty seconds. It talked me out of a purchase I was about to make based on 2012 SEO folklore.

Then it asked for exactly two things — and this was the entire manual part of the deployment:

Claude Code

1. A Coolify API token. (Coolify UI → Keys & Tokens → API tokens.)
2. You add an A record: jobs<VPS_IP>. And www.jobs while you are there.

I pasted the token. I added the records. I made tea.

Scene 3

The deploy, and the first thing that broke

It worked through the Coolify REST API. I watched it create, in order:

  1. A project to hold everything.
  2. A Postgres 16 resource — internal-only, generated password, written to a file on my machine.
  3. An application from my private GitHub repo, built with nixpacks (no Dockerfile — nixpacks sniffs the stack and figures it out), listening on port 3000.
  4. A deploy key — private half stored in Coolify, read-only public half pushed to the repo's deploy keys.
  5. The environment variables: DATABASE_URL, SITE_URL, and a fresh AUTH_SECRETgenerated new for production, not copied from my dev environment. I did not ask for that. It just did it.

Then it hit deploy. And the build failed.

Claude Code

Build failed. Here is what is happening, and I want to flag it because the obvious fix is a bad idea.

Next.js is trying to statically pre-render your pages at build time. Those pages query the database. But the build container has no network route to the internal Postgres — the database only accepts connections from the app's runtime network.

Chicken and egg: the build needs the database, and the database will not talk to the build.

The fix most people reach for is to expose the database publicly so the build can reach it. Don't. You would be putting your production database on the open internet permanently, to solve a problem that exists for ninety seconds during a build.

Me

So what do we do instead?

Claude Code

Skip static generation. Set a custom build command:

npx next build --experimental-build-mode compile

Pages then render on demand at runtime, with ISR caching — and at runtime the database is reachable. Database stays private. Build goes green.

bash — custom build command
npx next build --experimental-build-mode compile

It went green.

This is the shape of most real deployment bugs, by the way. Not “typo in config”. It is two systems having entirely reasonable opinions that happen to be incompatible — plus a tempting fix that trades a permanent security hole for a temporary convenience.

Scene 4

Moving the database off my laptop

Me

Migrate my local database to the production Postgres on the VPS. Verify the row counts match.

Note that second sentence again. “Verify” is a word you should be typing constantly.

The plan was simple: pg_dump locally, upload, then restore inside the Postgres container using docker exec psql. And then:

Claude Code

Restore failed. Version mismatch — your machine has pg_dump 18, the server runs Postgres 16. The dump contains a directive the older server does not recognise:

SET transaction_timeout = 0;

Stripping that line and re-running.

Restored. The row counts on local and production match.

That error message, pasted into Google, is an evening of your life. It took the agent about eight seconds.

📌 The rule to remember: always dump with a client that is the same version as your target server, or older. A newer pg_dump emits syntax that older servers have never heard of.
Scene 5

The HTTPS failure that fools everyone

Site deployed. I typed the URL into my browser, ready for my little moment.

Certificate error.

Claude Code

This is a known trap and it is nastier than it looks.

Coolify uses Traefik and Let's Encrypt to issue certificates automatically. But if DNS is not resolving yet when the app first deploys, the ACME challenge fails silently. No error in the UI. No retry. Your site just sits on plain HTTP forever, and the UI cheerfully tells you everything is fine.

Your DNS has propagated now. The fix is to restart the app, which re-triggers the certificate request.

Me

That's it? Restart?

Claude Code

That's it. The order matters, though — and this is the part every tutorial skips:

1. Add the DNS A record → 2. Wait for it to resolve → 3. Deploy the app.

Do it in that order and you never see this bug. Do it backwards — as we just did — and you get a silent, permanent failure with no error message anywhere.

Restart. Certificate issued. 🔒

And then, without being asked, it smoke-tested the live site: homepage returning 200 in 0.3 seconds, listings rendering, a detail page 200, sitemap.xml 200, and robots.txt correctly disallowing /admin.

Not “it works”. Here are the status codes. Ask for that. Always.

Scene 6

Making it run itself

Here is the thing that was still fake about my “live” product: the daily pipeline that pulls new listings was a Windows Task Scheduler job on my laptop. Laptop closed = site goes stale. That is not a product, that is a demo with a good disguise.

Me

The daily pipeline is running on my PC via Task Scheduler. Move it to the server as a Coolify scheduled task, and disable the local one. Run it in dry-run mode until I trust it.

That last sentence is me being paranoid, and I would encourage you to be the same. A brand-new automated job that writes to your production database should not be trusted on night one. Let it tell you what it would have done, first.

It created the Coolify scheduled task, disabled the Windows tasks, and deliberately left the “actually commit changes” flags off, so the first few runs were dry runs I could read.

Then it added a note I would have absolutely, definitely gotten wrong:

Claude Code

⏰ One thing: the server clock is UTC. A cron of 0 0 * * * is midnight UTC — which is 5:30 AM IST, not midnight your time. If you want 6 AM local, the cron is 30 0 * * *. Confirm which you want.

Every cron tutorial forgets to mention this, and every developer alive has had one job silently running at the wrong time for a month.

Scene 7

The part nobody films: locking the box down

The site was live. The data was in. The cron was running. I would have stopped here. In my head, I was done.

But something nagged at me — the same nag that makes you check the stove after you have locked the door.

Me

Do a full security audit of this VPS and tell me what's actually exposed.

I wish I had not asked. I am very glad I asked.

🔴 Finding 1: hundreds of failed root SSH login attempts. Every day.
Claude Code

Password authentication is enabled on SSH. Bots have been grinding on your root login around the clock — hundreds of failures a day, every day.

Fixed: key-only SSH, password authentication off. Simple.

Except it was not simple, and here is the gotcha that would have locked me out of my own server:

Claude Code

Careful here. sshd reads sshd_config.d/*.conf at the top of the config, and it is first-value-wins. Ubuntu ships a 50-cloud-init.conf that contains PasswordAuthentication yes.

That file silently beats any 99-hardening.conf you add later. Alphabetical order means 50 comes before 99, and first value wins — so your hardening file does nothing.

You would read your own config, see PasswordAuthentication no, and believe SSH was locked down. It would not be. I am editing the cloud-init file itself.

Read that twice. You think you hardened SSH. You did not. There are a lot of servers out there in exactly this state right now.

🔴 Finding 2: no brute-force protection at all.

Fixed: fail2ban. Five tries in ten minutes, then a one hour ban.

It banned two live attackers within seconds of starting. Not hypothetical attackers. Two people, actively trying, right then.

And another trap:

Claude Code

On Ubuntu 24.04, sshd logs to journald, not /var/log/auth.log. fail2ban's default file backend will watch an empty file and do absolutely nothing, forever, while reporting itself as healthy. You need backend = systemd in jail.local.

This is the single most common “fail2ban is installed but has never banned anyone” bug.

🔴 Finding 3: the Coolify admin panel was open to the entire internet.

Port 8000. The panel that controls every container on the box. Open to everyone.

Fixed: a firewall rule restricting port 8000 to my own IP. And then the third trap of the scene:

Claude Code

Two things about this firewall.

ufw is theatre here. On this provider the firewall sits upstream of the VM — the box's own iptables is empty with policy ACCEPT. Configuring ufw would make you feel safe and change nothing. The rule has to go in the provider's firewall.

And the API lies. It reported is_synced: true about two minutes before the rules actually took effect. Also, testing the block from your own IP proves nothing, because you are the one address that is allow-listed.

I verified from outside the network using third-party TCP probes. Port 8000 now refuses connections from everywhere except you. Confirmed, not assumed.

Also done in that pass: pending security updates applied (all OpenSSH CVEs), unattended-upgrades confirmed running, TLS confirmed valid on every host.

I want to be honest about this scene: none of these three holes existed because of the deploy. They had been sitting there the whole time. The deploy just gave me a reason to look.

Scene 8

“A dump on the same disk is not a backup”

One gap left, and it was the one that could have ended the whole project.

Me

I have no database backups. Set up something that survives the server dying, and prove the restore works.

Two halves came back.

On the VPS — a nightly pg_dump, run inside the Postgres container so the versions match (Scene 4's lesson, remembered without being told), gzipped, with 30-day retention. It writes to a .tmp file first, then renames, and verifies the “dump complete” marker — so a truncated dump can never quietly masquerade as a good one.

On my PC — a daily scheduled pull of those dumps to local disk. Because a backup that lives on the same disk as the database it is backing up is not a backup. It is a copy. When the disk dies, you lose both.

And then it did the thing that almost nobody does.

The restore drill

Claude Code

I am going to actually restore this backup and verify it. But there is a rule I want in the docs in bold, because getting this wrong destroys everything:

NEVER restore straight over the live database.

The dump uses --clean — which drops your live tables first, before restoring. If the dump turns out to be corrupt, you have just deleted the only copy of your data with your own hands, during the exact procedure meant to save it.

Restore into a scratch database. Count the rows. Then swap.

It ran the drill. A small gzipped dump rebuilt the database identically — every table, every row count matching, zero errors.

That is the thing that let me sleep. Not “backups are configured”. “Backups are configured, and I have restored one and counted the rows.”

And of course, Windows had one last laugh

The backup script was written on Windows, uploaded to Linux, and died instantly.

CRLF line endings. Bash reads the shebang as #!/bin/bash\r, goes looking for an interpreter literally named bash\r, does not find one, and gives up.

bash — strip CRLF line endings
sed -i 's/\r$//' your-script.sh

Thirty years of computing and we are still fighting about invisible characters.

Scene 9

The last prompt

Me

We're done. Revoke the Coolify API tokens.

Claude Code

Revoked. I no longer have the ability to create or destroy infrastructure on that server.

🔑 Do this. An API token you pasted into a chat window is a live credential with a long memory. The session ends; the token does not, unless you end it.

🎁 The prompt pack

The pattern that made all of this work: audit → decide → do one thing → verify → next.

Never “deploy my app” as one giant prompt. That is how you get an agent guessing.

Prompts — copy-paste, works for any full-stack app
1. AUDIT FIRST
   "I want to deploy this project to my VPS using Coolify. First, read the project and
    tell me what it needs in production. Then check the server: what's already running,
    what ports are taken, what could break. Don't change anything yet."

2. DECIDE
   "Walk me through the decisions: domain vs subdomain, database placement, how env vars
    and secrets get in. Give me your recommendation for each, and say what you need
    from me."

3. DEPLOY
   "Here's the Coolify API token. Create the project, the database (internal-only, no
    public port), and the app from my GitHub repo. Generate fresh production secrets —
    don't reuse my dev ones. Show me the env vars before you deploy."

4. DOMAIN + HTTPS
   "I've added the DNS A record and it resolves. Set the domain on the app and make sure
    the Let's Encrypt certificate actually issues — restart the app if the ACME challenge
    already failed."

5. DATABASE
   "Migrate my local database to production. Watch for pg_dump version mismatches.
    Verify the row counts match before you call it done."

6. VERIFY FOR REAL
   "Smoke test the live site: homepage, a detail page, sitemap, robots.txt. Report status
    codes and load times. Don't tell me it works — show me."

7. AUTOMATE
   "Move my scheduled jobs from my PC to the server as Coolify scheduled tasks. Remember
    the server is UTC. Run them in dry-run mode first."

8. SECURE
   "Full security audit of this VPS. What's exposed to the internet right now? Check SSH,
    brute-force protection, the firewall, and the Coolify panel port. Verify from outside
    the network, not from my IP."

9. BACK UP
   "Set up daily database backups that survive the server dying. Then prove the restore
    works by restoring into a scratch database — never over the live one."

10. CLEAN UP
    "We're done. Revoke the API tokens you were given."

The seven gotchas worth the whole post

  1. The build container can't reach your private database. Skip static prerendering with next build --experimental-build-mode compile. Do not expose the database to fix it.
  2. Let's Encrypt fails silently if DNS isn't ready. DNS first, then deploy. Restart the app to retry.
  3. sshd is first-value-wins, and 50-cloud-init.conf wins. Your hardening file may be doing nothing at all.
  4. fail2ban needs backend = systemd on Ubuntu 24.04. Otherwise it watches an empty log file and bans nobody, forever.
  5. Cloud firewalls are upstream — ufw is theatre. And “synced” lies. Verify from outside your own network.
  6. Cron on the server runs in UTC. Your 6 AM job is not running at 6 AM.
  7. A backup you have never restored is a hope, not a backup. And --clean drops your live tables — always drill into a scratch database first.

The honest ending

I gave maybe ten prompts. I pasted one token and added two DNS records. I never SSH'd into the server. I never wrote a Dockerfile. I never touched Nginx.

But go back and look at what actually made it work.

I made it audit before acting. I told it there was another live site on the box. I made it prove things instead of claiming them — row counts, status codes, an actual restore drill, a firewall verified from outside. I asked for dry runs before I trusted a job with my database. I asked the security question I badly wanted to skip.

The AI did the typing. Every single one of those decisions was mine.

That is the job now. Not typing the commands — knowing what has to be checked, and refusing to accept “it works” without evidence.

It is a skill. It is emphatically not a shortcut.

Discussion

Leave a Reply
Ready to Learn Backend?
Build Enterprise Apps

Master Express, MongoDB, PostgreSQL, Stripe integration, and deployment workflows in our live online classes.

Explore Courses
Also check: /courses/full-stack-ai-bootcamp.php — Full Stack Bootcamp