<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Appsvisor</title>
    <description>The latest articles on DEV Community by Appsvisor (@appsvisor).</description>
    <link>https://dev.to/appsvisor</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4245798%2F00485703-6e60-4dce-b213-7ce605cc4215.png</url>
      <title>DEV Community: Appsvisor</title>
      <link>https://dev.to/appsvisor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/appsvisor"/>
    <language>en</language>
    <item>
      <title>Hosting Several Client Apps on One VPS with Docker, Nginx and GitHub Actions</title>
      <dc:creator>Appsvisor</dc:creator>
      <pubDate>Sun, 15 Nov 2026 11:38:04 +0000</pubDate>
      <link>https://dev.to/appsvisor/hosting-several-client-apps-on-one-vps-with-docker-nginx-and-github-actions-3l25</link>
      <guid>https://dev.to/appsvisor/hosting-several-client-apps-on-one-vps-with-docker-nginx-and-github-actions-3l25</guid>
      <description>&lt;p&gt;Most small agencies and freelancers end up in the same place: a pile of small client projects. A Next.js marketing site here, a Laravel admin panel there, a Node API for a mobile app, a couple of staging copies. Paying for a separate managed platform for each one adds up fast, and a Kubernetes cluster is far more machinery than a handful of small apps need.&lt;/p&gt;

&lt;p&gt;This post walks through the setup we use to run several small apps on one VPS: a Docker Compose file per project, Nginx on the host as the reverse proxy, one database container per engine, and GitHub Actions to deploy on every push. Nothing clever. Boring is the point, because boring is easy to debug at 11 pm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the server
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Host:&lt;/strong&gt; Ubuntu LTS with Docker Engine, the Compose plugin, Nginx and Certbot installed directly on the host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apps:&lt;/strong&gt; every project lives in &lt;code&gt;/srv/apps/&amp;lt;project&amp;gt;/&amp;lt;env&amp;gt;/&lt;/code&gt; with its own &lt;code&gt;docker-compose.yml&lt;/code&gt; and &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ports:&lt;/strong&gt; each app container binds to &lt;code&gt;127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt; only, so nothing is exposed to the internet except Nginx on 80/443.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Databases:&lt;/strong&gt; one MySQL, one PostgreSQL and one MongoDB container on a private Docker network. Each engine holds both the prod and dev databases, with a separate user per database.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/srv
├── infra/
│   ├── databases/docker-compose.yml
│   ├── nginx-templates/site.conf
│   └── ports.txt
└── apps/
    ├── shop/
    │   ├── prod/docker-compose.yml
    │   └── dev/docker-compose.yml
    └── school-crm/
        ├── prod/docker-compose.yml
        └── dev/docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why Nginx on the host instead of Traefik or a self-hosted PaaS? Fewer moving parts, less RAM, and a config file you can read top to bottom. On a small VPS that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: a private network and shared databases
&lt;/h2&gt;

&lt;p&gt;Create one external network that apps and databases share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a single compose file for the database engines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mysql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql:8.4&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MYSQL_ROOT_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mysql-data:/var/lib/mysql&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;db&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pg-data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;db&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mysql-data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pg-data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No ports are published, so the databases can't be reached from the internet at all. Apps connect by service name (&lt;code&gt;mysql&lt;/code&gt;, &lt;code&gt;postgres&lt;/code&gt;), and when you need to open a database from your laptop, an SSH tunnel does the job.&lt;/p&gt;

&lt;p&gt;Give every project its own user and database per environment, for example &lt;code&gt;shop_prod&lt;/code&gt; and &lt;code&gt;shop_dev&lt;/code&gt;. A bad migration on dev then can't touch production data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: one compose file per app
&lt;/h2&gt;

&lt;p&gt;Here is a typical Next.js app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/your-org/shop-web:${TAG:-latest}&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"127.0.0.1:3101:3000"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;db&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep a plain &lt;code&gt;ports.txt&lt;/code&gt; that maps each project and environment to a port, for example 31xx for prod and 32xx for dev. It sounds trivial, but it is the thing that stops two apps fighting over the same port six months from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: an Nginx server block per domain
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;server_name&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;20m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;proxy_pass&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//127.0.0.1:3101;&lt;/span&gt;
        &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="nc"&gt;Host&lt;/span&gt; $&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="no"&gt;X&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Real&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="no"&gt;IP&lt;/span&gt; $&lt;span class="n"&gt;remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="no"&gt;X&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Forwarded&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;For&lt;/span&gt; $&lt;span class="n"&gt;proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;proxy_set_header&lt;/span&gt; &lt;span class="no"&gt;X&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Forwarded&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Proto&lt;/span&gt; $&lt;span class="n"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then issue a certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sudo certbot --nginx -d shop.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dev copies get a subdomain such as &lt;code&gt;dev.shop.example.com&lt;/code&gt; pointing to the 32xx port, behind basic auth so search engines and random visitors never see half-finished work.&lt;/p&gt;

&lt;p&gt;We wrapped this in a tiny &lt;code&gt;new-site.sh &amp;lt;domain&amp;gt; &amp;lt;port&amp;gt;&lt;/code&gt; script that fills the template, runs &lt;code&gt;nginx -t&lt;/code&gt; and reloads. Adding a new project is a few commands instead of a small adventure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: deploy on push with GitHub Actions
&lt;/h2&gt;

&lt;p&gt;Each repo carries its own Dockerfile, compose file and workflow. On every push to &lt;code&gt;main&lt;/code&gt;, the workflow builds an image, pushes it to GitHub Container Registry, then SSHes into the server to pull and restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;main&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;

      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/your-org/shop-web:${{ github.sha }}&lt;/span&gt;

      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_HOST }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SERVER_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;cd /srv/apps/shop/prod&lt;/span&gt;
            &lt;span class="s"&gt;export TAG=${{ github.sha }}&lt;/span&gt;
            &lt;span class="s"&gt;docker compose pull&lt;/span&gt;
            &lt;span class="s"&gt;docker compose up -d&lt;/span&gt;
            &lt;span class="s"&gt;docker image prune -f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that save headaches: create a dedicated &lt;code&gt;deploy&lt;/code&gt; user on the server that belongs to the &lt;code&gt;docker&lt;/code&gt; group, and log the server in to &lt;code&gt;ghcr.io&lt;/code&gt; once with a read-only token so it can pull private images.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about Laravel, Node or plain React apps?
&lt;/h2&gt;

&lt;p&gt;The same pattern works for any stack, because the host Nginx only ever sees a port. A Laravel app runs PHP-FPM behind a small web server inside its own compose file (or a single PHP image that bundles both), and the host Nginx proxies to it like any other app. Queue workers and the scheduler become extra services in the same compose file, reusing the same image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/your-org/crm-api:${TAG:-latest}&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php artisan queue:work --tries=3&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;db&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plain React or Vite build doesn't need a container at all. Build it in the workflow, copy the &lt;code&gt;dist&lt;/code&gt; folder to &lt;code&gt;/srv/static/&amp;lt;project&amp;gt;&lt;/code&gt; and let Nginx serve it with &lt;code&gt;try_files $uri /index.html;&lt;/code&gt;. Node APIs look exactly like the Next.js example above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a new project: the checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pick the next free port from &lt;code&gt;ports.txt&lt;/code&gt; and write it down.&lt;/li&gt;
&lt;li&gt;Create the database and user (&lt;code&gt;&amp;lt;project&amp;gt;_prod&lt;/code&gt;, &lt;code&gt;&amp;lt;project&amp;gt;_dev&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Copy the compose template into &lt;code&gt;/srv/apps/&amp;lt;project&amp;gt;/&amp;lt;env&amp;gt;/&lt;/code&gt; and fill in &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;new-site.sh &amp;lt;domain&amp;gt; &amp;lt;port&amp;gt;&lt;/code&gt;, then &lt;code&gt;certbot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add the server secrets to the GitHub repo and push to &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When adding a project takes ten minutes, nobody is tempted to "just run it on the same port for now", and that is how a shared server stays tidy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: the boring things that save you later
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uptime checks:&lt;/strong&gt; a small cron script that requests each domain every few minutes and sends a Telegram message and an email when something stops answering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups:&lt;/strong&gt; a nightly &lt;code&gt;mysqldump&lt;/code&gt; / &lt;code&gt;pg_dump&lt;/code&gt; per database, compressed, kept for a week, and copied off the server. A backup on the same disk is not a backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log limits:&lt;/strong&gt; Docker keeps container logs forever by default, which slowly fills the disk. Cap them in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"log-driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"json-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"log-opts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"max-size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"10m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"max-file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"3"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security updates:&lt;/strong&gt; turn on &lt;code&gt;unattended-upgrades&lt;/code&gt; and keep only ports 22, 80 and 443 open in the firewall.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When this stops being the right setup
&lt;/h2&gt;

&lt;p&gt;This pattern comfortably runs a good number of small and medium apps on a single VPS. It stops being the right tool when you need zero-downtime deploys across several servers, autoscaling for traffic spikes, or a larger team where many people deploy every day. At that point a managed platform or an orchestrator starts paying for itself.&lt;/p&gt;

&lt;p&gt;Until then, a few compose files, one Nginx config per domain and a short workflow per repo go a long way. We use this same pattern for the client projects we build at Appsvisor, from &lt;a href="https://www.appsvisor.com/services/web-development" rel="noopener noreferrer"&gt;web applications and business websites&lt;/a&gt; to the admin panels and APIs behind our &lt;a href="https://www.appsvisor.com/services/custom-software" rel="noopener noreferrer"&gt;custom software projects&lt;/a&gt;. If anything here is unclear, ask in the comments and I'll expand on it.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>nginx</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
