Docker & Containers

Full-Stack Web App Setup on a Fresh VPS (With Docker, Node.js, Prisma, Authentication, Frontend, and HTTPS)

By the Domain India teamPublished 9 min read
Knowledge base article
Contents (10 sections)

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.

This guide is for your own VPS

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.

Key takeaways

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

PartRuns asListens on
Nginx with Let's EncryptHost servicePublic ports 80 and 443
Frontend (Next.js or a React build)Docker container127.0.0.1:3000
API (Node.js, Express, Prisma)Docker container127.0.0.1:4000
PostgreSQLDocker containerInternal 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:

  1. Update the system.
    sudo apt update && sudo apt full-upgrade -y, then reboot if the kernel changed.
  2. 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.
  3. Enable the firewall.
    sudo ufw allow OpenSSH, sudo ufw allow 80,443/tcp, then sudo ufw enable.
  4. Turn on automatic security updates
    with 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:

bash
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 version

Use 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 bypasses UFW for published ports

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

text
app/
  compose.yaml
  .env                 # secrets, never committed
  backend/
    Dockerfile
    package.json
    prisma/schema.prisma
    prisma.config.ts
    src/db.ts
    src/index.ts
  frontend/
    Dockerfile

5. The API: Express, Prisma and PostgreSQL

In backend/, install the packages:

bash
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 postgresql

Add a user model to prisma/schema.prisma, keeping the generator and datasource blocks that prisma init created:

prisma
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:

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:

dockerfile
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:

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:

nginx
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.gz from cron, and copy the files off the server.
  • Updates: rebuild with docker compose up -d --build after git 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.

VPS Basic
₹1,105.30/mo + GST
  • 2 vCPU
  • 4 GB DDR4 RAM
  • 128 GB NVMe SSD Storage
  • 3 TB Monthly Bandwidth
See plan details

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.

App Developer
₹250/mo + GST
  • 512 MB RAM per app
  • 1.5 GB RAM total
  • 2 vCPU
  • 10 GB NVMe SSD
See plan details

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.

Run your full-stack app on your own server

Self-managed VPS plans with root access, NVMe storage and your choice of Linux.

See VPS plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app
Full-Stack App on a VPS: Docker, Node.js, Prisma, HTTPS