Skip to content
Infotech

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

AddressWhatOn the server
https://pos.example.comReact app (infotech-admin-web)Static files in /var/www/pos.example.com, served by nginx
https://api.example.comSpring Boot API (spring-admin) + PostgreSQLDocker Compose project infotech-pos, API on 127.0.0.1:8091, proxied by nginx

Folders on the server:

PathContents
/opt/infotech-pos/appThe Git clone. Overwritten on every deploy: never edit files here
/opt/infotech-pos/shared/.envSettings and secrets. Never in Git
/var/www/pos.example.comThe 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: the DOMAINS list and certificate name in server-setup.sh, the WEB folder and site check in deploy.sh, the file names and server_name lines in deploy/nginx/, and the URLs in env.production.example. deploy.sh also calls deploy/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.sh stops: set the two sites up in that proxy instead.
  • Port 8091 free on 127.0.0.1 for the API. If it is taken, choose another API_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:

NameTypeValue
pos.example.comAyour server IP
api.example.comAyour 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:

  1. Checks ports 80/443 and 8091 (see step 1).
  2. Installs the packages.
  3. Creates /opt/infotech-pos/shared/.env from deploy/env.production.example, asking for the super-admin email and password (at least 12 characters). It generates a random DB_PASSWORD (openssl rand -hex 24) and APP_ENCRYPTION_KEY (openssl rand -base64 32) and sets the file to mode 600. An existing .env is kept.
  4. Installs the nginx sites (step 6).
  5. Requests the HTTPS certificate, asking for an email for Let's Encrypt expiry notices (step 7).
  6. 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:

VariableSet to
API_PORTHost port of the API on 127.0.0.1 (default 8091). Must match the API's nginx site
VITE_API_URLhttps://api.example.com. Baked into the web app at build time
CORS_ORIGINShttps://pos.example.com. The only browser origin allowed to call the API and open the WebSocket
APP_URLhttps://pos.example.com. Payment gateways send shop owners back here
RESET_URLhttps://pos.example.com/reset-password. Link in password-reset emails
DB_PASSWORDGenerated. Do not change it after the database exists
APP_ENCRYPTION_KEYGenerated. Encrypts gateway secrets and mail. Back it up; never change it casually
BOOTSTRAP_EMAIL, BOOTSTRAP_PASSWORDFirst super administrator, created on first start only. Remove the password once you can sign in
REFRESH_SECURE_COOKIEtrue (HTTPS)
MAIL_ENABLED, MAIL_FROM, SMTP_*Your SMTP server. Set MAIL_ENABLED=true when ready; sign-up codes and password resets need it
DEMO_SEEDfalse. 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:

  1. Takes a lock so only one deploy runs at a time (waits up to 15 minutes).

  2. Loads /opt/infotech-pos/shared/.env.

  3. Fetches origin/main (or DEPLOY_BRANCH) and resets the clone to it with git reset --hard.

  4. API: when spring-admin/ or deploy/compose.prod.yaml changed (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 api
    

    It then polls http://127.0.0.1:$API_PORT/actuator/health until it reports UP (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.

  5. Web app: when infotech-admin-web/ changed (or with --all, or when the site has no index.html yet), builds the React app in a throwaway Node container with your VITE_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 ci runs only when node_modules is missing or package-lock.json changed. The dist/ output is copied to a new folder and swapped into /var/www/pos.example.com, so visitors never see a half-copied app.

  6. 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

Last updated 17 Oct 2026