Deploy the API and web app to a VPS
This page deploys Infotech POS from the Git repository to your own Linux VPS, the way the author runs the live service: the Spring Boot API and PostgreSQL in Docker Compose, the React app as static files served by the host's nginx, HTTPS from Let's Encrypt, and an automatic redeploy on every push (see Automatic deploys with GitHub Actions).
Note: If you bought the packaged release, you do not need this page. Use Install instead: one script, three containers, one address.
What you end up with
| Address | What | On the server |
|---|---|---|
https://pos.example.com | React app (infotech-admin-web) | Static files in /var/www/pos.example.com, served by nginx |
https://api.example.com | Spring Boot API (spring-admin) + PostgreSQL | Docker Compose project infotech-pos, API on 127.0.0.1:8091, proxied by nginx |
Folders on the server:
| Path | Contents |
|---|---|
/opt/infotech-pos/app | The Git clone. Overwritten on every deploy: never edit files here |
/opt/infotech-pos/shared/.env | Settings and secrets. Never in Git |
/var/www/pos.example.com | The built web app |
The repository contains everything for this in deploy/: server-setup.sh (one-time setup), deploy.sh (build and restart), env.production.example (the settings template), compose.prod.yaml (the API and database containers) and nginx/ (one site file per domain).
Note: The scripts and nginx files contain the author's domain names (
pos.infotech.center,posapi.infotech.center). In your fork, replace them with your own: theDOMAINSlist and certificate name inserver-setup.sh, theWEBfolder and site check indeploy.sh, the file names andserver_namelines indeploy/nginx/, and the URLs inenv.production.example.deploy.shalso callsdeploy/switch-domain.sh, a one-off helper for the author's own domain move; if it fails or is missing, the deploy prints a warning and carries on.
1. Prepare the server
You need:
- A Linux VPS (Ubuntu or Debian; the script uses
apt-get) with root access. 2 GB RAM or more is recommended. - Ports 80 and 443 free, or already used by the host's nginx. If a Docker proxy or another web server holds them,
server-setup.shstops: set the two sites up in that proxy instead. - Port 8091 free on
127.0.0.1for the API. If it is taken, choose anotherAPI_PORT(see step 4) and use the same port in the API's nginx site.
server-setup.sh installs the rest: git, curl, nginx, certbot, python3-certbot-nginx, Docker (via get.docker.com if missing) and the Docker Compose plugin, and enables Docker and nginx at boot.
2. Create the DNS records
At your DNS provider, add two A records pointing to the server's IP address:
| Name | Type | Value |
|---|---|---|
pos.example.com | A | your server IP |
api.example.com | A | your server IP |
Both names must resolve to the server before you request HTTPS certificates in step 7. Check with dig +short pos.example.com.
3. Clone with a deploy key
The server pulls the code from GitHub with a read-only deploy key. On the server, as root:
ssh-keygen -t ed25519 -N "" -C "vps-pull" -f ~/.ssh/infotech_pull
cat >> ~/.ssh/config <<'CFG'
Host github-infotech
HostName github.com
User git
IdentityFile ~/.ssh/infotech_pull
IdentitiesOnly yes
CFG
cat ~/.ssh/infotech_pull.pub
Copy the printed line (ssh-ed25519 …). On GitHub, open the repository, then Settings › Deploy keys › Add deploy key, paste it, leave Allow write access unticked and choose Add key.
Then clone into the expected folder (answer yes if asked to trust GitHub's host key):
mkdir -p /opt/infotech-pos
git clone git@github-infotech:YOUR-ACCOUNT/YOUR-REPO.git /opt/infotech-pos/app
4. Run the setup script
bash /opt/infotech-pos/app/deploy/server-setup.sh
It must run as root after the clone, and does six things in order:
- Checks ports 80/443 and 8091 (see step 1).
- Installs the packages.
- Creates
/opt/infotech-pos/shared/.envfromdeploy/env.production.example, asking for the super-admin email and password (at least 12 characters). It generates a randomDB_PASSWORD(openssl rand -hex 24) andAPP_ENCRYPTION_KEY(openssl rand -base64 32) and sets the file to mode 600. An existing.envis kept. - Installs the nginx sites (step 6).
- Requests the HTTPS certificate, asking for an email for Let's Encrypt expiry notices (step 7).
- Builds and starts everything with
deploy.sh --all(step 5). The first build takes about 5–10 minutes.
At the end it creates the auto-deploy key and prints three GitHub secrets. Add them as described in Automatic deploys with GitHub Actions.
The script can be run again safely: it keeps the existing .env, nginx site files and certificate.
The settings file
Review /opt/infotech-pos/shared/.env after the first run (nano /opt/infotech-pos/shared/.env). The important values:
| Variable | Set to |
|---|---|
API_PORT | Host port of the API on 127.0.0.1 (default 8091). Must match the API's nginx site |
VITE_API_URL | https://api.example.com. Baked into the web app at build time |
CORS_ORIGINS | https://pos.example.com. The only browser origin allowed to call the API and open the WebSocket |
APP_URL | https://pos.example.com. Payment gateways send shop owners back here |
RESET_URL | https://pos.example.com/reset-password. Link in password-reset emails |
DB_PASSWORD | Generated. Do not change it after the database exists |
APP_ENCRYPTION_KEY | Generated. Encrypts gateway secrets and mail. Back it up; never change it casually |
BOOTSTRAP_EMAIL, BOOTSTRAP_PASSWORD | First super administrator, created on first start only. Remove the password once you can sign in |
REFRESH_SECURE_COOKIE | true (HTTPS) |
MAIL_ENABLED, MAIL_FROM, SMTP_* | Your SMTP server. Set MAIL_ENABLED=true when ready; sign-up codes and password resets need it |
DEMO_SEED | false. Only for a public demo server |
Every variable is described in Environment variables. After any change, run deploy.sh --all so both the API and the web app pick it up.
5. Build and run the API and the web app
deploy.sh does the building. server-setup.sh runs it once with --all; later runs come from GitHub Actions or by hand:
sudo /opt/infotech-pos/app/deploy/deploy.sh --all
What it does:
-
Takes a lock so only one deploy runs at a time (waits up to 15 minutes).
-
Loads
/opt/infotech-pos/shared/.env. -
Fetches
origin/main(orDEPLOY_BRANCH) and resets the clone to it withgit reset --hard. -
API: when
spring-admin/ordeploy/compose.prod.yamlchanged (or with--all), builds and starts the API container next to PostgreSQL:docker compose -p infotech-pos --env-file /opt/infotech-pos/shared/.env \ -f /opt/infotech-pos/app/deploy/compose.prod.yaml up -d --build apiIt then polls
http://127.0.0.1:$API_PORT/actuator/healthuntil it reportsUP(up to 3 minutes). If it does not, it prints the last 80 API log lines and fails. Flyway migrates the database during this start. -
Web app: when
infotech-admin-web/changed (or with--all, or when the site has noindex.htmlyet), builds the React app in a throwaway Node container with yourVITE_API_URL:docker run --rm -e VITE_API_URL="$VITE_API_URL" \ -v /opt/infotech-pos/app/infotech-admin-web:/app -w /app node:22-alpine \ sh -c "npm ci --no-audit --no-fund && npm run build"npm ciruns only whennode_modulesis missing orpackage-lock.jsonchanged. Thedist/output is copied to a new folder and swapped into/var/www/pos.example.com, so visitors never see a half-copied app. -
Removes unused Docker images.
Without --all, only what changed since the last deploy is rebuilt.
6. Configure nginx
server-setup.sh copies deploy/nginx/<domain>.conf to /etc/nginx/sites-available/, links them into sites-enabled/, tests with nginx -t and reloads nginx. Existing site files are not overwritten, so edit them on the server after the first run.
The web app site (deploy/nginx/pos.example.com.conf) serves the static files and sends unknown paths to index.html, because the React app handles routes such as /shop/dashboard itself:
# React app (static files). Certbot adds the HTTPS part to this file.
server {
listen 80;
listen [::]:80;
server_name pos.example.com;
root /var/www/pos.example.com;
index index.html;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# Hashed build files never change: cache for a year.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# The app shell and service worker must always be fresh so new deploys show up.
location = /index.html { add_header Cache-Control "no-cache"; }
location = /sw.js { add_header Cache-Control "no-cache"; }
# Single-page app: every route falls back to index.html.
location / {
try_files $uri $uri/ /index.html;
}
}
The API site (deploy/nginx/api.example.com.conf), including the live-updates WebSocket:
# Spring Boot API. Certbot adds the HTTPS part to this file.
map $http_upgrade $posapi_ws_connection {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name api.example.com;
client_max_body_size 5m;
location / {
proxy_pass http://127.0.0.1:8091;
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;
# Live updates WebSocket (/api/v1/live)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $posapi_ws_connection;
proxy_read_timeout 3600s;
}
}
If you changed API_PORT, change 8091 in the API site too. The long proxy_read_timeout keeps the live-updates connection open; without the Upgrade lines, drafts, tables and the kitchen display stop updating live.
7. Turn on HTTPS
server-setup.sh requests one Let's Encrypt certificate for both names, adds the HTTPS server blocks and an HTTP-to-HTTPS redirect to the nginx sites:
certbot --nginx --non-interactive --agree-tos --redirect -m you@example.com \
--cert-name pos.example.com -d pos.example.com -d api.example.com
The certbot package renews certificates automatically. If DNS was not ready, fix it and run the same command again.
8. Verify
curl -fsS http://127.0.0.1:8091/actuator/health # API inside the server: {"status":"UP"…}
curl -fsS https://api.example.com/actuator/health # API through nginx and HTTPS
docker compose -p infotech-pos -f /opt/infotech-pos/app/deploy/compose.prod.yaml ps
Then open https://pos.example.com and sign in with the super-admin email and password. Activate your license under Platform & license (see License): until then the platform console and shop sign-up answer "license required" on a real domain, while existing shops keep selling. Set up email next, then follow First steps.
If sign-in fails with a network or CORS error, check that CORS_ORIGINS is exactly https://pos.example.com and that the web app was built with the right VITE_API_URL. If drafts or the kitchen display do not update live, check the /api/v1/live block.
Day-to-day commands
# API logs
docker compose -p infotech-pos -f /opt/infotech-pos/app/deploy/compose.prod.yaml logs -f api
# Container status
docker compose -p infotech-pos -f /opt/infotech-pos/app/deploy/compose.prod.yaml ps
# Change settings, then rebuild everything
nano /opt/infotech-pos/shared/.env
sudo /opt/infotech-pos/app/deploy/deploy.sh --all
nginx logs are in /var/log/nginx/. Super administrators can also see warnings and errors under Server status in the console.
Back up the database
docker compose -p infotech-pos -f /opt/infotech-pos/app/deploy/compose.prod.yaml exec -T postgres \
pg_dump -U pos_admin pos_admin | gzip > /opt/infotech-pos/backup-$(date +%F).sql.gz
To run it daily, put the command in a script and add a cron entry for it. Copy the backups and /opt/infotech-pos/shared/.env off the server regularly: without APP_ENCRYPTION_KEY, saved gateway keys cannot be read from a restored database.
To restore into an empty database:
gunzip -c /opt/infotech-pos/backup-YYYY-MM-DD.sql.gz | \
docker compose -p infotech-pos -f /opt/infotech-pos/app/deploy/compose.prod.yaml exec -T postgres \
psql -U pos_admin -d pos_admin
Related pages
Last updated 17 Oct 2026