Deploy a Private RAG App on Debian 12 VPS

What you are building
This tutorial shows you how to deploy a private RAG app on a fresh Debian 12 VPS, from the first SSH login through HTTPS verification and rollback checks. You will run a small production-style stack: Nginx as the reverse proxy, Docker Compose for the app layer, PostgreSQL for metadata, and Qdrant for vector search.
The goal is not a demo. You are setting up a service that can survive routine updates, restarts, and failed deployments without exposing your data or making recovery painful. If you need a larger VPS as your workload grows, Hostperl VPS hosting is the right starting point for this kind of build: Hostperl VPS hosting.
This guide targets Debian 12 because it is the least-covered OS track for this run, and it fits the stack cleanly. Debian 13 works with the same architecture, but this tutorial uses Debian 12 commands and package names.
Architecture and decision points
A private RAG app has three parts that need separate care. The web app handles authentication and requests, PostgreSQL stores application records and job state, and Qdrant stores vectors for retrieval.
Keeping them in separate containers makes upgrades and rollback much easier.
You will also keep the service private behind Nginx. That lets you terminate TLS at the edge, enforce rate limits later, and keep the app port closed to the public internet.
For teams comparing storage and memory footprints before launch, this sizing guide for private AI infrastructure helps you choose a VPS that is not too small for indexing and retrieval.
Use this design if you need controlled access, predictable update windows, and a clean way to restore data after a bad release. If your workload is customer-facing and tied to uptime, this is safer than exposing the app directly on a public port.
1) Connect to the VPS and identify Debian
On your local computer, open your first SSH session with the example address below.
ssh root@203.0.113.10203.0.113.10 is a reserved documentation address. Replace it with the real public IP assigned to your Hostperl server.
If your provider gave you a default non-root login, use that account instead:
ssh deploy@203.0.113.10Once connected, identify the operating system before you install anything.
On the VPS as root:
cat /etc/os-releaseYou should see Debian 12 details such as ID=debian and VERSION_ID="12". If you are on Debian 13, the steps still apply, but package versions may differ slightly.
2) Create a non-root administrator and keep root open
Do not disable root access until you have tested the new account in a second terminal. That avoids lockout while you are still setting up SSH keys and sudo access.
On the VPS as root, create the account, add sudo access, and set a password you can use once for the first login test:
adduser deployFollow the prompts and choose a strong password. Then grant sudo rights:
usermod -aG sudo deployNow prepare SSH access. If your public key is already on your local computer, copy it into place with ssh-copy-id from the local machine:
ssh-copy-id deploy@203.0.113.10That command appends your local public key to /home/deploy/.ssh/authorized_keys. If you need to do it manually, use this safer sequence on the VPS:
sudo -iu deploy mkdir -p /home/deploy/.ssh
sudo -iu deploy chmod 700 /home/deploy/.ssh
sudo -iu deploy touch /home/deploy/.ssh/authorized_keys
sudo -iu deploy chmod 600 /home/deploy/.ssh/authorized_keysPaste your public key into /home/deploy/.ssh/authorized_keys with a text editor, then set ownership:
chown -R deploy:deploy /home/deploy/.sshOn your local computer, open a second terminal and test the new login before changing any SSH policy:
ssh deploy@203.0.113.10After login, verify sudo access:
sudo -vIf that works, keep both sessions open and continue in the root shell for the remaining server setup.
3) Update the system, time sync, and basic hardening
On the VPS as root, update package lists and install the basic tools you will use throughout the build:
apt update
apt -y full-upgrade
apt -y install ca-certificates curl gnupg git ufw fail2ban chronyDebian uses apt, and chrony keeps the system clock accurate. That matters for TLS, log correlation, and token expiry.
Enable time sync and check the status:
systemctl enable --now chrony
chronyc trackingYou should see a stable time source and a small offset.
Set a clean hostname if needed:
hostnamectl set-hostname server.example.comUse server.example.com as the example hostname in this tutorial and replace it with your own fully qualified name if you have one.
4) Build a safe firewall rule set
Open SSH first, then add HTTP and HTTPS. Do not remove SSH access until you have tested the new account and confirmed the firewall allows it.
On the VPS as root:
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw status verboseYou should see OpenSSH, 80/tcp, and 443/tcp listed as allowed. The app port will stay private on the loopback interface only.
For customers who later need a tighter perimeter, Hostperl’s network and VPS support teams can help align the firewall with your launch window and DNS cutover. That is one reason many small teams keep application hosting on a managed VPS rather than scattering services across multiple hosts.
5) Install Docker and Docker Compose on Debian
This stack uses Docker because it gives you a clean rollback path. You can stop one Compose file and start another without changing the host packages.
On the VPS as root, install Docker from Debian packages:
apt -y install docker.io docker-compose-plugin
systemctl enable --now docker
docker --version
docker compose versionIf Docker starts correctly, the version commands should print current package versions and the service should be active.
Add your non-root administrator to the Docker group so you can manage the stack without root:
usermod -aG docker deployLog out and back in as deploy so group membership takes effect.
6) Create the application layout and environment file
On the VPS as the non-root sudo user, create the application directory structure:
sudo mkdir -p /opt/myapp/{app,nginx,postgres,qdrant}
sudo chown -R deploy:deploy /opt/myapp
cd /opt/myapp
pwdNow create the environment file that will hold secrets and connection strings:
nano /opt/myapp/.envPaste this content into the file:
POSTGRES_DB=myapp
POSTGRES_USER=myapp
POSTGRES_PASSWORD=change_this_to_a_long_unique_password
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
QDRANT_URL=http://qdrant:6333
APP_PORT=8000
APP_SECRET_KEY=change_this_to_another_long_unique_secretSave and exit. In nano, press Ctrl+O, Enter, then Ctrl+X.
Restrict the file so only the owner can read it:
chmod 600 /opt/myapp/.env7) Add a simple private RAG app container
For a production tutorial, the app image should be predictable. This example uses a small Python API container with a health endpoint and placeholders for retrieval logic.
Replace it with your actual application code later, but keep the deployment pattern.
On the VPS as the non-root sudo user, create the app file:
nano /opt/myapp/app/DockerfilePaste this Dockerfile:
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn[standard] psycopg[binary] httpx
COPY app.py /app/app.py
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]Save and exit, then create the app source file:
nano /opt/myapp/app/app.pyPaste this minimal private RAG app skeleton:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/")
def root():
return {"message": "private RAG app online"}This tutorial keeps the code small so the hosting pattern stays in focus. In a real deployment, your retrieval layer would query PostgreSQL and Qdrant and return an answer assembled from your indexed documents.
8) Define PostgreSQL, Qdrant, and the app in Compose
On the VPS as the non-root sudo user, create the Compose file:
nano /opt/myapp/compose.ymlPaste this content:
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql/data
restart: unless-stopped
qdrant:
image: qdrant/qdrant:v1.13.1
volumes:
- ./qdrant:/qdrant/storage
restart: unless-stopped
app:
build: ./app
env_file:
- .env
depends_on:
- postgres
- qdrant
ports:
- "127.0.0.1:8000:8000"
restart: unless-stoppedSave and exit. The app port binds only to 127.0.0.1, so nobody can reach it directly from the internet.
Build and start the stack:
cd /opt/myapp
docker compose up -d --build
docker compose psThe containers should show Up. If one fails, inspect it immediately rather than continuing.
9) Check logs and fix container startup issues
On the VPS as the non-root sudo user, check the app logs if the stack does not come up cleanly:
docker compose logs --no-color --tail=100 app
docker compose logs --no-color --tail=100 postgres
docker compose logs --no-color --tail=100 qdrantIf PostgreSQL reports authentication errors, confirm that the password in .env matches the Compose file. If Qdrant fails, check disk space with df -h and permissions on /opt/myapp/qdrant.
A useful Hostperl reference for release hygiene is Docker Compose Release Readiness for VPS Launches. It pairs well with this deployment pattern because it treats the host and the Compose stack as one release unit.
10) Put Nginx in front and keep the app private
Nginx will handle public traffic, TLS, and request forwarding to the private app. The app stays on localhost.
On the VPS as root, install Nginx:
apt -y install nginx
systemctl enable --now nginx
nginx -vCreate a site configuration:
nano /etc/nginx/sites-available/myappPaste this configuration:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:8000;
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;
}
}
Enable the site and test syntax before reloading:
ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
nginx -t
systemctl reload nginxnginx -t must report that the configuration is successful. If it fails, fix the file before reloading.
11) Add Let’s Encrypt TLS
Once DNS for example.com points to your VPS, request a certificate. Replace the domain with your real hostname before you run the command.
On the VPS as root:
apt -y install certbot python3-certbot-nginx
certbot --nginx -d example.comChoose the redirect option when prompted so HTTP moves to HTTPS. Certbot will update Nginx and install an auto-renewal timer.
Confirm renewal plumbing now, not after launch:
systemctl list-timers | grep certbot
certbot renew --dry-runIf the dry run succeeds, your certificate renewal path is ready.
12) Harden SSH after the new login is verified
Only do this after you have confirmed the deploy account can log in and use sudo. Keep the root session open until the end of this step.
On the VPS as root, edit SSH settings:
nano /etc/ssh/sshd_configSet or add these lines:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yesTest the SSH daemon configuration first:
sshd -tIf there is no output, the config syntax is valid. Then reload SSH:
systemctl reload sshOpen a new terminal and confirm you can still connect as deploy. Do not close the original root session until that test passes.
13) Add Fail2Ban for noisy login attempts
On the VPS as root, create a small jail override for SSH:
nano /etc/fail2ban/jail.d/sshd.localPaste this content:
[sshd]
enabled = true
maxretry = 3
findtime = 10m
bantime = 1hSave the file and start the service:
systemctl enable --now fail2ban
systemctl restart fail2ban
fail2ban-client status sshdYou should see the SSH jail active. This is a practical safeguard for public-facing admin ports.
14) Verify the stack from the server and from a client
On the VPS as the non-root sudo user, confirm the services and listening ports:
docker compose ps
ss -ltnp | grep -E '(:80|:443|:8000)'
journalctl -u nginx -n 50 --no-pagerYou should see Nginx listening on 80 and 443, while the app stays bound to localhost on 8000.
On your local computer, test the public site:
curl -I https://example.com
curl https://example.com/healthSuccessful output should show a valid HTTPS response and a JSON health payload. If TLS is wrong, check the certificate name and the DNS record.
Run one functional smoke test from the server itself as well:
curl http://127.0.0.1:8000/healthIf that works locally but not externally, the problem is almost always Nginx, DNS, or firewall rules.
15) Reboot test and persistence check
On the VPS as root, confirm the service survives a reboot:
rebootReconnect after the host returns, then check:
systemctl is-active nginx
systemctl is-active docker
systemctl is-active fail2banLog in as deploy and confirm the Compose stack resumed:
cd /opt/myapp
docker compose psIf the containers do not restart, verify that the restart: unless-stopped policy is still present in compose.yml.
Rollback and recovery path
If a new release breaks the app, stop the stack, restore the previous Compose file, and restart cleanly. That is the benefit of keeping the app, database, and vector store isolated.
On the VPS as the non-root sudo user:
cd /opt/myapp
docker compose down
cp compose.yml compose.yml.broken.$(date +%F-%H%M%S)
# restore your last known-good compose.yml here
docker compose up -d --build
docker compose psIf the database files become corrupted, restore from a volume snapshot or your last validated backup. For teams that use PostgreSQL heavily, Hostperl’s PostgreSQL logical backup guide for Debian 12 is a good companion to this setup. If you prefer point-in-time recovery, this PITR recovery tutorial shows the restore pattern in a production context.
Troubleshooting the most likely failures
Nginx returns 502 Bad Gateway
Run:
systemctl status nginx --no-pager
curl -I http://127.0.0.1:8000
journalctl -u nginx -n 100 --no-pagerIf the local curl fails, the app container is down. Rebuild the stack with docker compose up -d --build. If Nginx logs show upstream connection refused, confirm the proxy target is 127.0.0.1:8000.
Certbot fails to issue a certificate
Run:
dig +short example.com
ss -ltnp | grep ':80'
certbot certificatesIf DNS does not point to the VPS or port 80 is blocked, fix that first. Certbot needs public reachability for the domain you requested.
Docker containers do not start after reboot
Run:
systemctl status docker --no-pager
docker compose ps
docker compose logs --tail=50If the Docker service is inactive, enable it with systemctl enable --now docker. If the app container keeps exiting, inspect the application logs and the environment file.
Wrap-up
You now have a private RAG app running on Debian 12 with a locked-down public edge, Docker-based rollback, PostgreSQL storage, Qdrant vector search, and TLS in front of the app. That is a practical production baseline, not a lab exercise.
For teams planning their next deployment window, Hostperl VPS hosting gives you the control needed for containers, reverse proxies, and private application workloads without losing the support path that matters during cutover week. If you are also comparing launch-readiness practices, this release checklist for VPS deployments is worth pairing with the stack you just built.
If you want to host a private RAG app with less guesswork, Hostperl can provide the VPS foundation, network support, and migration help that make the first release calmer. A Hostperl VPS is a good fit when you need Docker, PostgreSQL, TLS, and room to grow without re-architecting on day two.
For teams that expect real users, not just test traffic, Hostperl’s support approach is built around launch windows, recovery, and steady operations.
FAQ
Can I run this on Ubuntu or AlmaLinux?
Yes. The app architecture is portable, but the package manager, firewall, and SSH service names change. Debian 12 is used here because this tutorial is written for the Debian track.
Why keep Qdrant and PostgreSQL in separate containers?
It makes backup, restore, and failure isolation much simpler. If the vector store needs a rebuild, your metadata database still survives.
Should I expose port 8000 to the internet?
No. Keep it on localhost and let Nginx handle public traffic. That reduces your attack surface and makes TLS easier to manage.
What is the fastest way to test a broken deployment?
Start with docker compose ps, then check each container log with docker compose logs. After that, test curl http://127.0.0.1:8000/health locally and curl -I https://example.com from your client.
