Part D — Per-project setup (repeat for every new website)
This is what to do each time you start a brand new site. In practice, tell Claude Code you're starting a new project and want this same setup — this section is really "what to ask for," including the exact file contents it should produce.
The one message that kicks off dev → git → prod, end to end
You don't have to ask for D1 through D7 one at a time — a single message to Claude Code can cover building it on your laptop (dev), getting it onto GitHub (git), and getting it live on your VPS (prod). For example:
"I want to build a new website called
your-project-name. Please:
- Scaffold it as a Next.js + TypeScript + Tailwind app in a new folder.
- Let me look at it locally (
npm run dev) so I can approve the starting point.- Set it up with a
Dockerfileanddocker-compose.prod.ymlthe same way as my other projects, so it can run on my VPS behind Traefik.- Create a private GitHub repo for it and push the first commit.
- Once I say go, deploy it to my VPS at
/docker/your-project-name, routed for now toyour-project-name.<my-hostinger-default-domain>so I can see it live before I buy a real domain.Walk me through it step by step, and confirm with me before anything gets pushed to GitHub or deployed to the VPS."
Claude Code will still pause at each real checkpoint (Part G) — this single message just means you don't have to re-explain the whole setup pattern every time you start something new. Everything after this point (Parts D1–D7 below) is what that message actually results in, broken down in detail.
D1. Scaffold the app
For a Next.js site:
npx create-next-app@latest your-project-name
Answer the prompts (TypeScript: yes, Tailwind: yes, App Router: yes are good defaults). Other frameworks (Astro, SvelteKit, plain React) work too — tell Claude Code which one you want and it'll adjust the Docker setup to match.
D2. Add a Dockerfile
This tells Docker how to build your site into a container. For a Next.js
app, this template works as-is — save it as a file named exactly
Dockerfile (no file extension) in the project's root folder:
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
This requires next.config.ts to include output: "standalone" — ask
Claude Code to check or add that.
D3. Add docker-compose.prod.yml
This tells Docker how to run the container, and tells Traefik which
domain should route to it. Replace your-project-name and
yourdomain.com with your actual values:
services:
web:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000"
environment:
NODE_ENV: production
PORT: "3000"
HOSTNAME: "0.0.0.0"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://127.0.0.1:3000', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
labels:
- traefik.enable=true
- traefik.http.routers.your-project-name.rule=Host(`yourdomain.com`) || Host(`www.yourdomain.com`)
- traefik.http.routers.your-project-name.entrypoints=websecure
- traefik.http.routers.your-project-name.tls.certresolver=letsencrypt
- traefik.http.services.your-project-name.loadbalancer.server.port=3000
D3a. Using your Hostinger default domain (no real domain needed yet)
Hostinger gives every VPS a free default hostname out of the box, something
like srv123456.hstgr.cloud. You can point a new
project at that immediately, so you can see it live and share a real link
before you've bought (or pointed DNS for) an actual domain — just add it as
an extra Host() option in the same label from D3 above:
- traefik.http.routers.your-project-name.rule=Host(`your-project-name.srv123456.hstgr.cloud`) || Host(`yourdomain.com`) || Host(`www.yourdomain.com`)
Each project gets its own prefix (your-project-name.) on the same shared
default domain, so multiple sites on one VPS can each have their own
temporary address without clashing.
Rather than editing the YAML by hand, you can just ask Claude Code:
"My Hostinger VPS came with a default domain,
srv123456.hstgr.cloud. I just created a new site calledyour-project-name— routeyour-project-name.srv123456.hstgr.cloudto its container using the same Traefik setup as my other projects, so I can see it live right away. Keepyourdomain.comin there too for when I point a real domain at it later."
Once you do buy a real domain, Part C5 (pointing its DNS A record at your VPS) is the only extra step — the routing rule is already in place.
D3b. Repointing a domain you already own (e.g. a free one from Hostinger)
Some Hostinger plans include a free domain when you sign up. Pointing that domain (or any domain you already own) at your new project works the same way as Part C5 — an A record aimed at your VPS's IP address — except you often don't need to click through the DNS panel yourself at all:
"I have a domain,
yourdomain.com, that I'd like to point at my VPS so it resolves to the container foryour-project-name."
If Claude Code has a way to reach your domain's DNS settings — for example through a browser session you're already logged into hPanel with — it can make this change directly in hPanel's DNS Zone editor, the same way it runs commands on your VPS over SSH, rather than you needing to find and edit the DNS record by hand.
Heads up — this makes your site live instantly, ready or not. The moment DNS points at your VPS and Traefik picks up the routing label, real visitors can reach that domain — even if the project behind it is still just the default scaffolded starter page. There's no "are you sure it's ready" step in between. Repointing a domain straight at a freshly created container really can make a site reachable within minutes, showing exactly whatever happens to be in the project at that moment.
Ship a "Coming Soon" page before anything else
Because going live is instant, it's worth asking Claude Code to build a simple placeholder page as the very first thing deployed — before any real content exists — so a visitor (or you, just confirming it worked) sees something intentional instead of a half-built or broken-looking default page:
"Before we build out the real site, replace the homepage with a simple 'Coming soon' page — just a heading and a short line saying the site is under construction, nothing else. We'll build the real pages after confirming this is live."
Once that's deployed and confirmed working, keep building the real site on top of it exactly as described in Part F, one change at a time.
D4. Add .gitignore entries
Make sure these lines are in your project's .gitignore file (Claude Code
normally adds this automatically when scaffolding — double-check it's
there):
node_modules
.next/
.env*
/.claude/
.env* matters most — it stops secrets (API keys, passwords) from ever
being uploaded to GitHub by accident.
D5. Add deploy.sh
This is the one-command deploy script — the whole point of Part C2 was to make this script able to run with no password and no typed username at all. You have two ways to get it into your project: copy the template below yourself, or — easier, and recommended — just ask Claude Code to write it for you, tailored to your actual VPS details.
How to get Claude Code to create it for you
Open a Claude Code session inside your project folder (run claude in a
terminal there) and give it a message like this, filled in with your own
real details from Part C2:
"Create a
deploy.shscript for this project. It should: commit-check first (refuse to run if there are uncommitted changes), then push the current branch to GitHub, then SSH into my VPS and pull the latest code and rebuild the Docker container withdocker compose -f docker-compose.prod.yml up -d --build, then confirm the container comes back up healthy.My VPS details:
- SSH host:
YOUR_VPS_IP- SSH user:
root- SSH key:
~/.ssh/id_ed25519_myvps- Project folder on the VPS:
/docker/your-project-name- Container name:
your-project-name-web-1- Branch to deploy:
mainMake it executable, and add a
.gitattributesentry so it always keeps Linux-style line endings even though I'm editing it on Windows."
What Claude Code will typically do in response:
- Write the
deploy.shfile with your specific values already filled in (no placeholders left for you to hunt down and edit). - Run
chmod +x deploy.shto make it runnable. - Add the
*.sh text eol=lfline to.gitattributes(this one detail matters more than it sounds — a shell script saved with Windows-style line endings can fail to run at all on the Linux VPS with a crypticbad interpretererror; the.gitattributesline prevents that permanently). - Explain the script back to you and ask before running it the first time.
Before trusting it for real, ask Claude Code to test it on a harmless
change first — e.g. edit a comment or a piece of placeholder text,
commit it, then run ./deploy.sh and confirm the live site actually
updated. That way the first real use of the script is low-stakes.
The template, if you'd rather write it yourself
Save as deploy.sh in the project root, fill in your own values at the
top, then make it runnable:
chmod +x deploy.sh
#!/usr/bin/env bash
set -euo pipefail
VPS_HOST="${DEPLOY_HOST:-YOUR_VPS_IP}"
VPS_USER="${DEPLOY_USER:-root}"
VPS_KEY="${DEPLOY_KEY:-$HOME/.ssh/id_ed25519_myvps}"
VPS_PATH="${DEPLOY_PATH:-/docker/your-project-name}"
BRANCH="${DEPLOY_BRANCH:-main}"
if [ -n "$(git status --porcelain)" ]; then
echo "You have uncommitted changes. Commit them first, or they won't be deployed:"
git status --short
exit 1
fi
echo "==> Pushing local commits to GitHub ($BRANCH)..."
git push origin "$BRANCH"
echo "==> Deploying on $VPS_USER@$VPS_HOST:$VPS_PATH..."
ssh -i "$VPS_KEY" -o BatchMode=yes "$VPS_USER@$VPS_HOST" bash -s <<EOF
set -euo pipefail
cd "$VPS_PATH"
git pull origin "$BRANCH"
docker compose -f docker-compose.prod.yml up -d --build
EOF
echo "==> Waiting for the container to report healthy..."
sleep 5
ssh -i "$VPS_KEY" -o BatchMode=yes "$VPS_USER@$VPS_HOST" \
"docker ps --filter name=your-project-name-web-1 --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
echo "==> Done."
("Healthy" here means Docker ran a quick built-in check — the healthcheck
block above — and confirmed the site is actually responding, not just that
the container started.)
Also add a file named .gitattributes with this one line in it, so the
script never breaks from Windows-vs-Linux line-ending differences:
*.sh text eol=lf
D6. Get the code onto the VPS the first time
ssh myvps "git clone https://github.com/yourusername/your-project-name.git /docker/your-project-name"
(For a private repo, the VPS needs its own permission slip to download it — called a deploy key. Ask Claude Code to set one up for you.)
Then run the very first deploy manually:
ssh myvps "cd /docker/your-project-name && docker compose -f docker-compose.prod.yml up -d --build"
D7. Write a DEPLOYMENT.md
Ask Claude Code to write one for the new project — a plain-English record of the VPS IP, SSH key path, project path on the VPS, and troubleshooting steps specific to that site. Future-you (or anyone else working on it later) will thank you.
Published