Host AI Apps on VPS with pgvector and Docker Compose

Why this setup fits private AI apps
If you want to host AI apps on VPS for support bots, sales assistants, or RAG workflows, a compact private stack is often simpler than stitching together serverless services. You keep the app, database, vector search, and background jobs in one place, which makes data residency, audit trails, and debugging much easier. For teams that want that control without building everything from scratch, a Hostperl VPS is a practical starting point.
This tutorial uses Docker Compose, PostgreSQL with pgvector, and a Node.js app that exposes a simple /ask endpoint. You can adapt the same pattern for OpenClaw, Next.js, NestJS, FastAPI, Qdrant, or Redis-backed queues. If you are comparing private hosting models first, the related guide on private AI app hosting with RAG and security is a useful companion.
What you will deploy
We will build a small but real AI service:
- PostgreSQL 16 with pgvector for embeddings
- A Node.js API for chat and retrieval requests
- Redis for caching and queue work
- Nginx as the public reverse proxy
- Let's Encrypt TLS for the domain
Use these placeholders throughout the tutorial:
SERVER_IP= your VPS IP, for example203.0.113.10DOMAIN_NAME= your domain, for exampleai.example.nzAPP_USER= the non-root admin user, for examplehostperlAPP_DIR= the app directory, for example/opt/ai-stackAPP_PORT= the internal app port, for example3000
1) Connect to the VPS and confirm the OS
From your local machine, SSH into the server as root. If your provider gave you a default non-root account, use that instead.
ssh root@203.0.113.10Once connected, confirm the operating system before installing anything else.
cat /etc/os-releaseYou should see either Ubuntu/Debian or AlmaLinux/Rocky Linux. The remaining steps are split where commands differ.
2) Create a non-root admin user
Keep the root session open until the new login works. Open a second terminal and complete the steps below on the VPS as root.
Ubuntu and Debian
adduser hostperl
usermod -aG sudo hostperl
mkdir -p /home/hostperl/.ssh
chmod 700 /home/hostperl/.ssh
cat /root/.ssh/authorized_keys > /home/hostperl/.ssh/authorized_keys
chown -R hostperl:hostperl /home/hostperl/.ssh
chmod 600 /home/hostperl/.ssh/authorized_keysThis creates hostperl, grants sudo, and copies the existing SSH key so you can test the new account safely.
AlmaLinux and Rocky Linux
useradd -m hostperl
passwd hostperl
usermod -aG wheel hostperl
mkdir -p /home/hostperl/.ssh
chmod 700 /home/hostperl/.ssh
cat /root/.ssh/authorized_keys > /home/hostperl/.ssh/authorized_keys
chown -R hostperl:hostperl /home/hostperl/.ssh
chmod 600 /home/hostperl/.ssh/authorized_keysOn these systems, sudo access comes from the wheel group. After this, open a second terminal and test the new login.
ssh hostperl@203.0.113.10
sudo -v
whoami
pwdYou should land on the server as hostperl, and sudo -v should prompt for your password or succeed. Only then should you tighten root access.
3) Install Docker, Compose, Nginx, and Git
On the VPS as the non-root sudo user, install the base packages. Hostperl customers usually choose this path because it keeps deployment state readable and easier to recover after a support call or migration.
Ubuntu and Debian
sudo apt update
sudo apt install -y ca-certificates curl gnupg git nginx docker.io docker-compose-plugin
sudo systemctl enable --now nginx dockerCheck versions so you know what you are running.
nginx -v
docker --version
docker compose versionAlmaLinux and Rocky Linux
sudo dnf install -y dnf-utils ca-certificates curl git nginx docker docker-compose-plugin
sudo systemctl enable --now nginx dockerThen confirm the services are active.
nginx -v
docker --version
docker compose version
sudo systemctl status nginx --no-pager
sudo systemctl status docker --no-pager4) Open the firewall before you close anything down
Open SSH, HTTP, and HTTPS first. Do not remove your working root session until the new user and firewall are verified.
Ubuntu and Debian with UFW
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verboseAlmaLinux and Rocky Linux with firewalld
sudo systemctl enable --now firewalld
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
sudo firewall-cmd --list-allIf you later disable password SSH login, test key-based access in a second terminal first.
5) Create the application directory and Compose stack
On the VPS as hostperl, create the app directory and move into it. This keeps the deployment in one place, which helps with backups and rollback.
sudo mkdir -p /opt/ai-stack
sudo chown -R hostperl:hostperl /opt/ai-stack
cd /opt/ai-stack
pwdNow create the Compose file.
nano docker-compose.ymlPaste this file content exactly, replacing only the sample passwords and hostnames.
services:
postgres:
image: pgvector/pgvector:pg16
container_name: ai_postgres
environment:
POSTGRES_DB: aiapp
POSTGRES_USER: aiapp
POSTGRES_PASSWORD: change-this-db-password
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7.4-alpine
container_name: ai_redis
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata:/data
restart: unless-stopped
app:
image: node:22-alpine
container_name: ai_app
working_dir: /app
command: sh -c "npm install && node server.js"
env_file:
- .env
volumes:
- ./:/app
ports:
- "127.0.0.1:3000:3000"
depends_on:
- postgres
- redis
restart: unless-stopped
volumes:
pgdata:
redisdata:Save and exit with Ctrl+O, Enter, then Ctrl+X.
6) Add secure environment variables
Create a private .env file. This is where you keep API keys for model providers, webhook secrets, and database settings.
nano .envUse this sample.
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://aiapp:change-this-db-password@postgres:5432/aiapp
REDIS_URL=redis://redis:6379
OPENAI_API_KEY=replace-with-your-key
EMBEDDING_MODEL=text-embedding-3-small
CHAT_MODEL=gpt-4.1-mini
WEBHOOK_SECRET=replace-with-a-long-random-stringProtect the file immediately after saving.
chmod 600 .envFor audit-friendly deployments, this file stays on the VPS and is not committed to Git.
7) Create the Node.js app
The next file gives you a working retrieval endpoint and a health check. It is intentionally small so you can swap in Next.js, NestJS, FastAPI, or OpenClaw later.
nano server.jsconst http = require('http');
const { Pool } = require('pg');
const Redis = require('ioredis');
const port = process.env.PORT || 3000;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);
async function init() {
await pool.query('CREATE EXTENSION IF NOT EXISTS vector;');
await pool.query(`CREATE TABLE IF NOT EXISTS docs (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
embedding vector(3)
)`);
await pool.query(`INSERT INTO docs (title, body, embedding)
VALUES ('Support hours', 'We answer billing and migration tickets 24/7 from NZ/APAC coverage windows.', '[0.1,0.2,0.3]')
ON CONFLICT DO NOTHING`);
}
function json(res, code, data) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
const server = http.createServer(async (req, res) => {
if (req.url === '/health') {
return json(res, 200, { ok: true });
}
if (req.url === '/ask') {
const cached = await redis.get('answer:sample');
if (cached) return json(res, 200, { source: 'cache', answer: cached });
const result = await pool.query('SELECT title, body FROM docs LIMIT 1');
const answer = `Matched: ${result.rows[0].title} — ${result.rows[0].body}`;
await redis.set('answer:sample', answer, 'EX', 300);
return json(res, 200, { source: 'db', answer });
}
json(res, 404, { error: 'not found' });
});
init()
.then(() => server.listen(port, '0.0.0.0'))
.then(() => console.log(`Listening on ${port}`))
.catch(err => {
console.error(err);
process.exit(1);
});Save and exit, then create the package manifest.
nano package.json{
"name": "ai-stack",
"version": "1.0.0",
"main": "server.js",
"type": "commonjs",
"dependencies": {
"ioredis": "^5.4.1",
"pg": "^8.13.0"
}
}Install the app dependencies inside the container later, so you do not need Node directly on the VPS.
8) Start the stack and check the containers
Bring everything up on the VPS as hostperl.
docker compose up -d
docker compose psYou should see three running containers. If the app exits, inspect the logs next.
docker compose logs --tail=100 appA clean startup should end with Listening on 3000.
9) Put Nginx in front of the app
Create a reverse proxy so visitors reach the app on standard web ports. This is the point where Hostperl customers often finish a migration from a serverless prototype to a private VPS deployment.
sudo nano /etc/nginx/conf.d/ai-stack.confserver {
listen 80;
server_name DOMAIN_NAME;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Replace DOMAIN_NAME with your real domain. Test syntax before you reload.
sudo nginx -t
sudo systemctl reload nginxYou should see syntax is ok and test is successful.
10) Add TLS with Let's Encrypt
Point your DNS A record to SERVER_IP first. Then install the certificate tool and request a certificate.
Ubuntu and Debian
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d DOMAIN_NAMEAlmaLinux and Rocky Linux
sudo dnf install -y certbot python3-certbot-nginx
sudo certbot --nginx -d DOMAIN_NAMEChoose the redirect option so HTTP goes to HTTPS. Then test renewal.
sudo certbot renew --dry-run11) Test the app from the server and your laptop
On the VPS, confirm the app responds locally.
curl -s http://127.0.0.1:3000/health | jq
curl -s http://127.0.0.1:3000/ask | jqFrom your local computer, test the public site.
curl -s https://DOMAIN_NAME/health | jq
curl -s https://DOMAIN_NAME/ask | jqExpected result: {"ok":true} on health and a JSON answer for /ask. If you see cache hits, Redis is working too.
12) Check persistence, logs, and restart behavior
Restart the host and confirm the services come back automatically.
sudo rebootAfter reconnecting, run these checks on the VPS.
docker compose ps
sudo systemctl status nginx --no-pager
sudo systemctl status docker --no-pager
curl -s https://DOMAIN_NAME/health | jqIf everything returns cleanly after reboot, your deployment is ready for real customer traffic, support bots, or internal workflow agents.
Troubleshooting the most likely failures
- Nginx returns 502. Run
docker compose logs --tail=100 app. If the app crashed on startup, fix the missing package or bad environment variable, then rundocker compose up -dagain. - Database extension errors. Run
docker compose exec postgres psql -U aiapp -d aiapp -c '\dx'. Ifvectoris missing, confirm you used the pgvector image, not stock Postgres. - TLS issuance fails. Run
sudo nginx -tand check that DNS forDOMAIN_NAMEpoints toSERVER_IP. Then rerunsudo certbot --nginx -d DOMAIN_NAME. - SSH lockout risk. Keep the root session open until a second terminal proves that
hostperlcan log in and use sudo.
Where this pattern fits next
Once this stack is live, you can swap the sample Node app for a real RAG pipeline, a Next.js admin panel, or a private sales bot. Many Hostperl customers start here, then move into dedicated resources or a larger managed VPS hosting plan when token volume, queue depth, or vector size grows.
If you are comparing deployment models before your next rollout, the article on Vercel alternatives and private bots explains why a private VPS often wins on control, cost visibility, and data residency. For retrieval-heavy builds, the tutorial on RAG app hosting with pgvector on a Hostperl VPS is the next logical step.
Hostperl is a strong fit if you want to host AI apps on VPS with support you can actually reach, especially for private bots, RAG services, and regional workloads. If you need a right-sized setup with room for PostgreSQL, Redis, Docker Compose, and TLS, start with Hostperl VPS hosting and scale as traffic grows.
For teams comparing private deployments with a more controlled footprint, managed VPS hosting gives you a practical base for migrations, backups, and launch-day fixes.
FAQ
Can I use this setup for Next.js or FastAPI?
Yes. Keep Nginx, TLS, PostgreSQL, and Redis the same, then replace the app container with your framework image or process.
Is pgvector enough for a small RAG app?
For many customer-service bots and internal assistants, yes. It is easy to back up, works well with Postgres, and keeps your stack simple.
How do I keep secrets safe?
Store them in .env, protect the file with chmod 600, and avoid committing it to Git. For larger teams, move secrets into your deployment pipeline or a vault tool.
What if I later need Qdrant or Chroma?
Add another container to Compose and point your retrieval code at the new vector service. The reverse proxy and TLS steps do not change.
How does this help with APAC latency?
A VPS closer to your users usually cuts response time and keeps support agents and internal workflows feeling faster, especially for New Zealand and nearby APAC traffic.
