This guide takes a fresh Ubuntu VPS to a working full-stack app: a Node.js API with Express, Prisma and PostgreSQL, password login with JSON Web Tokens (JWT), a frontend, and HTTPS, all running in Docker Compose behind Nginx. It is written for 2026 tooling and avoids the common traps, such as Docker quietly opening ports past your firewall.
Everything here needs root access and applies to a VPS or server you manage yourself, with no control panel. It does not apply to shared hosting, where you can't install Docker or change server configuration.
Harden the VPS first (sudo user, key-only SSH, firewall on 22, 80 and 443). Install Docker Engine and the Compose plugin from Docker's own repository. Run PostgreSQL, the API and the frontend as Compose services, publish the app ports on 127.0.0.1 only, and keep the database on the internal Docker network. Put Nginx on the host in front, get a certificate with Certbot, and run prisma migrate deploy on every release.
1. What we are building
| Part | Runs as | Listens on |
|---|---|---|
| Nginx with Let's Encrypt | Host service | Public ports 80 and 443 |
| Frontend (Next.js or a React build) | Docker container | 127.0.0.1:3000 |
| API (Node.js, Express, Prisma) | Docker container | 127.0.0.1:4000 |
| PostgreSQL | Docker container | Internal Docker network only |
The site answers at https://example.com/ and the API at https://example.com/api/.... Because both share one origin, the browser needs no CORS rules.
2. Prepare the VPS
Use a current LTS release such as Ubuntu 24.04 LTS, and point your domain's A record at the VPS IP. Then:
- Update the system.
sudo apt update && sudo apt full-upgrade -y, then reboot if the kernel changed. - Create a sudo user and use SSH keys.Log in as that user and turn off password and root logins in the SSH server configuration.
- Enable the firewall.
sudo ufw allow OpenSSH,sudo ufw allow 80,443/tcp, thensudo ufw enable. - Turn on automatic security updateswith
unattended-upgrades.
The full baseline, including Fail2ban, is in from zero to production: the complete VPS setup guide and VPS firewall setup.
3. Install Docker Engine and Compose
Install from Docker's official apt repository, not the older docker.io or docker-compose packages:
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
docker compose versionUse docker compose (with a space); the old docker-compose v1 command is retired. Adding your user to the docker group saves typing sudo, but that group is equivalent to root, so add only trusted users.
Docker writes its own firewall rules, so a port published as "4000:4000" is open to the whole internet even if UFW blocks it. Publish app ports as "127.0.0.1:4000:4000" so only Nginx on the same server can reach them, and never publish the database port.
4. Project layout
app/
compose.yaml
.env # secrets, never committed
backend/
Dockerfile
package.json
prisma/schema.prisma
prisma.config.ts
src/db.ts
src/index.ts
frontend/
Dockerfile5. The API: Express, Prisma and PostgreSQL
In backend/, install the packages:
npm init -y
npm pkg set type=module
npm install express helmet express-rate-limit bcryptjs jsonwebtoken prisma @prisma/client @prisma/adapter-pg dotenv
npm install -D typescript @types/express @types/jsonwebtoken @types/node
npx prisma init --datasource-provider postgresqlAdd a user model to prisma/schema.prisma, keeping the generator and datasource blocks that prisma init created:
model User {
id Int @id @default(autoincrement())
email String @unique
password String
createdAt DateTime @default(now())
}Run npx prisma migrate dev --name init on your development machine and commit the prisma/migrations folder. The shared client in src/db.ts, the config file and the Prisma 6 differences are explained in our Prisma guide.
The API with registration and login, src/index.ts:
import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { prisma } from './db.js';
const app = express();
const JWT_SECRET = process.env.JWT_SECRET!;
app.set('trust proxy', 1); // behind Nginx: use the real client IP
app.use(helmet());
app.use(express.json({ limit: '100kb' }));
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 20 });
app.post('/api/register', authLimiter, async (req, res) => {
const { email, password } = req.body ?? {};
if (typeof email !== 'string' || typeof password !== 'string' || password.length < 10) {
res.status(400).json({ error: 'Email and a password of 10+ characters are required' });
return;
}
const hash = await bcrypt.hash(password, 12);
try {
const user = await prisma.user.create({ data: { email: email.toLowerCase(), password: hash } });
res.status(201).json({ id: user.id, email: user.email });
} catch {
res.status(409).json({ error: 'Account already exists' });
}
});
app.post('/api/login', authLimiter, async (req, res) => {
const { email, password } = req.body ?? {};
const user = typeof email === 'string'
? await prisma.user.findUnique({ where: { email: email.toLowerCase() } })
: null;
if (!user || typeof password !== 'string' || !(await bcrypt.compare(password, user.password))) {
res.status(401).json({ error: 'Invalid email or password' });
return;
}
const token = jwt.sign({ sub: String(user.id) }, JWT_SECRET, { expiresIn: '15m' });
res.json({ token });
});
app.get('/api/me', (req, res) => {
const token = req.headers.authorization?.replace(/^Bearer /, '');
try {
const payload = jwt.verify(token ?? '', JWT_SECRET) as jwt.JwtPayload;
res.json({ user: payload.sub });
} catch {
res.status(401).json({ error: 'Not signed in' });
}
});
app.listen(4000, '0.0.0.0');Keep access tokens short-lived. For browser apps, sending the token in an httpOnly, Secure cookie is safer than keeping it in localStorage. For choosing a hashing algorithm, see secure password hashing with bcrypt and Argon2.
A multi-stage backend/Dockerfile, assuming a tsconfig.json with "module": "nodenext" that compiles src/ to dist/ and a build script that runs prisma generate && tsc:
FROM node:24-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app ./
USER node
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]prisma migrate deploy applies only migrations you have already committed, so each release updates the schema before the API starts.
6. The frontend
Build the frontend in its own container. For Next.js, set output: 'standalone' in next.config and run node server.js from the standalone build on port 3000. For a plain React app built with Vite, you can skip the container and let Nginx serve the dist/ folder directly. Call the API with relative URLs such as fetch('/api/login').
7. Compose it together
compose.yaml:
services:
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
restart: unless-stopped
api:
build: ./backend
environment:
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:4000:4000"
restart: unless-stopped
web:
build: ./frontend
ports:
- "127.0.0.1:3000:3000"
restart: unless-stopped
volumes:
pgdata:Put POSTGRES_PASSWORD and a long random JWT_SECRET (for example from openssl rand -hex 32) in .env next to the file, readable only by you. Start everything with docker compose up -d --build and follow logs with docker compose logs -f api.
8. Nginx and HTTPS
Install Nginx and Certbot on the host with sudo apt install -y nginx certbot python3-certbot-nginx, then create /etc/nginx/sites-available/app:
server {
listen 80;
server_name example.com www.example.com;
location /api/ {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Enable it with sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/, check it with sudo nginx -t, reload Nginx, then run sudo certbot --nginx -d example.com -d www.example.com. Certbot adds the HTTPS server block and renews the certificate automatically. proxy_pass has no trailing slash here, so the /api prefix reaches Express unchanged.
9. Keep it running
- Backups: dump the database daily with
docker compose exec -T db pg_dump -U app app | gzip > backup.sql.gzfrom cron, and copy the files off the server. - Updates: rebuild with
docker compose up -d --buildaftergit pull, and refresh base images regularly for security fixes. - CI/CD: run tests and deploy from GitHub Actions; see CI/CD deploys to a VPS with GitHub Actions.
10. Running this on Domain India
Domain India VPS plans are self-managed and come with root access. cPanel is not offered on VPS, and this guide needs no control panel. Your login details are covered in accessing your VPS after purchase. You can reboot from inside the server over SSH; for a reinstall, console access or a resize, open a ticket. A small version of this stack fits in 2 GB of RAM, but building images needs memory, so 4 GB gives more headroom.
- 2 vCPU
- 4 GB DDR4 RAM
- 128 GB NVMe SSD Storage
- 3 TB Monthly Bandwidth
If you would rather not manage a server, the App Platform detects Node.js apps automatically and includes a PostgreSQL database on every plan. It does not support WebSockets.
- 512 MB RAM per app
- 1.5 GB RAM total
- 2 vCPU
- 10 GB NVMe SSD
The cards show live Domain India prices, excluding 18% GST.
Do I need Docker to run a Node.js app on a VPS?
No. You can run Node.js directly with PM2 or systemd behind Nginx. Docker makes the environment repeatable and keeps the app, database and frontend in separate containers that start with one command.
Why can people reach my container port even though UFW blocks it?
Docker adds its own firewall rules for published ports, which take effect before UFW's rules. Publish app ports on 127.0.0.1, for example 127.0.0.1:4000:4000, and never publish the database port.
How do I run Prisma migrations in production?
Create migrations on your development machine with prisma migrate dev and commit them. In production, run only prisma migrate deploy, for example in the container's start command before the API starts.
Where should I store the JWT secret?
In an environment variable loaded from a .env file on the server that is readable only by you and never committed to Git. Use a long random value, for example from openssl rand -hex 32.
Can I install Docker on Domain India shared hosting?
No. Shared hosting does not give root access or allow Docker. Use a Domain India VPS, which is self-managed with root access, or the App Platform, which runs Node.js apps and Dockerfiles for you.
Which VPS size do I need for this stack?
A small app with PostgreSQL, a Node.js API and a light frontend can fit in 2 GB of RAM. Building Docker images, especially Next.js, uses a lot of memory, so a 4 GB plan gives more room.
Ready to deploy? Compare VPS plans, try the managed route on the App Platform, or open a support ticket if you are not sure which fits your app.
Self-managed VPS plans with root access, NVMe storage and your choice of Linux.
See VPS plans