todos os artigos
/// Coding & DevOps & Infraestrutura ///

Deploying a PHP Application to Ubuntu VPS

A step-by-step guide for deploying a long-running PHP application with optional WebSocket support to an Ubuntu VPS, using GitHub Actions for CI/CD, Nginx, Certbot and systemd.

23 de April de 2026 · ~12 min de leitura · #php #deploy #vps

A step-by-step guide for deploying a long-running PHP application (with optional WebSocket support) to an Ubuntu VPS, using GitHub Actions for CI/CD.

Based on: the logstream-server deployment on an OCI Ubuntu VPS. Tested on: Ubuntu 22.04 / 24.04 (LTS), PHP 8.3, Nginx, Certbot.

· · ·

1. Prerequisites

On your local machine / GitHub:

  • Repository hosted on GitHub
  • GitHub Actions enabled
  • The following GitHub Secrets created (Settings → Secrets → Actions):
Secret Description
SSH_HOST Server IP or hostname
SSH_USER Deploy user (e.g. deploy)
SSH_PORT SSH port (usually 22)
SSH_PRIVATE_KEY Private SSH key for the deploy user
HEALTH_CHECK_URL Full URL to your app’s health endpoint

Add additional secrets for anything app-specific (database passwords, API tokens, etc.).

· · ·

2. Server preparation

SSH into your server as root (or a sudoer):

ssh root@your-server-ip

Update the system:

apt update && apt upgrade -y
apt install -y curl wget git unzip software-properties-common \
               nginx certbot python3-certbot-nginx ufw
· · ·

3. Install PHP and extensions

Add the Ondřej PPA for up-to-date PHP packages:

add-apt-repository ppa:ondrej/php -y
apt update

Install PHP 8.3 and the extensions your app needs:

# Core (always needed)
apt install -y php8.3-cli php8.3-mbstring php8.3-xml php8.3-curl php8.3-zip

# Optional — pick what your app uses
apt install -y php8.3-mysql     # MariaDB / MySQL
apt install -y php8.3-sockets   # ReactPHP / long-running processes
apt install -y php8.3-pcntl     # Process control (signal handling)
apt install -y php8.3-redis     # Redis
apt install -y php8.3-gd        # Image processing

Install Composer globally:

curl -sS https://getcomposer.org/installer | php
mv composer.phar /usr/local/bin/composer
chmod +x /usr/local/bin/composer

Verify:

php8.3 --version
composer --version
· · ·

4. Create the application user and directory

Using a dedicated system user means the app never runs as root and file permissions stay clean.

# Replace 'myapp' with your application name throughout
APP=myapp

# Create system user (no login shell, no home directory)
useradd --system --no-create-home --shell /usr/sbin/nologin $APP

# Create the application directory
mkdir -p /opt/$APP
chown $APP:$APP /opt/$APP
chmod 750 /opt/$APP

Create a deploy user that GitHub Actions will SSH in as:

useradd --system --create-home --shell /bin/bash deploy

Grant the deploy user ownership of the app directory so it can git pull and run Composer:

chown -R deploy:deploy /opt/$APP
· · ·

5. Deploy SSH key and sudoers

SSH key

On your local machine, generate a dedicated deploy key:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_myapp -N ""

Copy the public key to the server:

ssh-copy-id -i ~/.ssh/deploy_myapp.pub deploy@your-server-ip

Or manually append it:

# On the server
mkdir -p /home/deploy/.ssh
echo "YOUR_PUBLIC_KEY_HERE" >> /home/deploy/.ssh/authorized_keys
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh

Add the private key (~/.ssh/deploy_myapp) as the SSH_PRIVATE_KEY GitHub Secret.

Sudoers — allow deploy to restart the service without a password

cat > /etc/sudoers.d/$APP-deploy << EOF
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart $APP
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl start $APP
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop $APP
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl status $APP
EOF

chmod 440 /etc/sudoers.d/$APP-deploy

Verify the file is valid:

visudo -c -f /etc/sudoers.d/$APP-deploy
· · ·

6. Clone the repository and install dependencies

As the deploy user:

su - deploy

git clone git@github.com:your-org/your-repo.git /opt/$APP
cd /opt/$APP

# Install production dependencies (no dev packages)
composer install --no-dev --optimize-autoloader

If your repo is private, add the deploy key as a GitHub Deploy Key (Settings → Deploy keys → Add) using the same public key, with read-only access.

· · ·

7. Environment configuration

Create the .env file (never commit this to the repository):

cp /opt/$APP/.env.example /opt/$APP/.env
nano /opt/$APP/.env

Typical contents for a PHP app:

APP_ENV=production
APP_DEBUG=false

# HTTP
HTTP_PORT=8081

# Secrets
APP_SECRET=change-me-to-a-strong-random-value

# Database (if used)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=myapp
DB_USER=myapp
DB_PASS=change-me

Lock down permissions:

chown $APP:$APP /opt/$APP/.env
chmod 640 /opt/$APP/.env
· · ·

8. Systemd service

Systemd keeps your PHP process alive, starts it on boot, and restarts it on failure.

cat > /etc/systemd/system/$APP.service << EOF
[Unit]
Description=My PHP Application
After=network.target
# If your app needs the database, add:
# After=network.target mariadb.service

[Service]
Type=simple
User=$APP
Group=$APP
WorkingDirectory=/opt/$APP
ExecStart=/usr/bin/php8.3 bin/server.php
Restart=on-failure
RestartSec=5s

# Environment file (loaded before the process starts)
EnvironmentFile=/opt/$APP/.env

# Logging — output goes to journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=$APP

# ── Security hardening ─────────────────────────────────────────────────────
# Comment these out if running inside an LXC container (OCI VMs are usually LXC)
# ProtectSystem=strict
# PrivateTmp=true
# NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
EOF

Enable and start the service:

systemctl daemon-reload
systemctl enable $APP
systemctl start $APP
systemctl status $APP

Watch live logs:

journalctl -u $APP -f
· · ·

9. Nginx site

cat > /etc/nginx/sites-available/$APP << EOF
server {
    listen 80;
    server_name your-domain.com;

    # Redirect HTTP → HTTPS (Certbot will manage this automatically)
    return 301 https://\$host\$request_uri;
}

server {
    listen 443 ssl;
    server_name your-domain.com;

    # SSL certificates (Certbot will fill these in)
    ssl_certificate     /etc/letsencrypt/live/your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
    include             /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Content-Type-Options    "nosniff"                             always;
    add_header X-Frame-Options           "SAMEORIGIN"                          always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin"     always;

    # Proxy all requests to PHP
    location / {
        proxy_pass         http://127.0.0.1:8081;
        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;
        proxy_read_timeout 60s;
    }
}
EOF

Enable the site:

ln -s /etc/nginx/sites-available/$APP /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx
· · ·

10. SSL with Certbot

Make sure your domain’s DNS A record already points to the server’s public IP before running Certbot.

certbot --nginx -d your-domain.com

Certbot will: verify domain ownership over HTTP, obtain a Let’s Encrypt certificate, automatically update your Nginx config with SSL settings, and install a cron/systemd timer for auto-renewal.

Test renewal:

certbot renew --dry-run
· · ·

11. GitHub Actions workflows

ci.yml — runs on every pull request

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: /tmp/composer-cache
          key: $-$

      - name: Install dependencies
        uses: php-actions/composer@v6

      - name: PHPUnit tests
        uses: php-actions/phpunit@v4
        with:
          version: 11
          php_version: "8.3"
          php_extensions: xdebug curl mbstring
          configuration: phpunit.xml
          args: --coverage-filter src tests
        env:
          XDEBUG_MODE: coverage

release.yml — runs on push to main, deploys to the server

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: php-actions/composer@v6
      - uses: php-actions/phpunit@v4
        with:
          version: 11
          php_version: "8.3"
          configuration: phpunit.xml

  deploy:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh
          echo "$" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -p $ $ >> ~/.ssh/known_hosts

      - name: Deploy
        run: |
          ssh -p $ \
              $@$ << 'REMOTE'
            set -e
            cd /opt/myapp

            echo "→ Pulling latest code"
            git pull origin main

            echo "→ Installing dependencies"
            composer install --no-dev --optimize-autoloader --no-interaction

            echo "→ Restarting service"
            sudo systemctl restart myapp

            echo "→ Waiting for service to come up"
            sleep 3
          REMOTE

      - name: Health check
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
                   --retry 5 --retry-delay 3 \
                   "$")
          if [ "$STATUS" != "200" ]; then
            echo "Health check failed — HTTP $STATUS"
            exit 1
          fi
          echo "Health check passed — HTTP $STATUS"
· · ·

12. Optional: WebSocket support

If your application runs a WebSocket server on a separate port (e.g. 8080), add a second proxy block to Nginx.

Nginx — add inside the server { listen 443 ssl; ... } block

# WebSocket upgrade proxy
location /ws {
    proxy_pass          http://127.0.0.1:8080;
    proxy_http_version  1.1;

    # Required for WebSocket upgrade
    proxy_set_header    Upgrade    $http_upgrade;
    proxy_set_header    Connection "upgrade";
    proxy_set_header    Host       $host;

    # Keep connections alive long enough for real-time use
    proxy_read_timeout  3600s;
    proxy_send_timeout  3600s;
}

Systemd — the same service handles both ports

Your PHP process listens on both ports internally. No second service is needed.

Firewall — WebSocket traffic goes through Nginx (443), not directly

Do not open port 8080 on the firewall. Clients connect to wss://your-domain.com/ws which Nginx proxies to the internal port. Keep port 8080 closed externally.

· · ·

13. Optional: MariaDB

Install and secure MariaDB:

apt install -y mariadb-server
mysql_secure_installation

Create a database and dedicated user for the app:

mysql -u root -p << SQL
CREATE DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'myapp'@'127.0.0.1' IDENTIFIED BY 'strong-password-here';
GRANT ALL PRIVILEGES ON myapp.* TO 'myapp'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL

Run any migration scripts:

mysql -u myapp -p myapp < /opt/$APP/migrations/001_schema.sql

Running migrations in GitHub Actions (via WireGuard VPN)

If the database is not directly reachable from GitHub Actions (it shouldn’t be), use WireGuard.

Add these secrets: WIREGUARD_CONFIG, MYSQL_SERVER, MYSQL_USER_MIGRATION, MYSQL_PASSWORD_MIGRATION, MYSQL_DATABASE.

Add this job to release.yml:

  migrate:
    needs: deploy
    runs-on: ubuntu-latest
    if: contains(join(github.event.commits.*.modified, ','), 'migrations/')

    steps:
      - uses: actions/checkout@v4

      - name: Install WireGuard
        run: sudo apt-get install -y wireguard

      - name: Connect VPN
        run: |
          echo "$" | sudo tee /etc/wireguard/wg0.conf
          sudo wg-quick up wg0

      - name: Run pending migrations
        run: |
          for f in migrations/*.sql; do
            echo "Running $f..."
            mysql -h $ \
                  -u $ \
                  -p$ \
                  $ < "$f"
          done

      - name: Disconnect VPN
        if: always()
        run: sudo wg-quick down wg0
· · ·

14. Firewall rules

# Allow SSH, HTTP, HTTPS — nothing else
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
ufw status verbose

Internal ports (8081 for HTTP, 8080 for WebSocket) are not opened — they are only reachable via Nginx on the same machine.

If the server is behind an OCI/cloud security list, mirror these rules there too: Ingress TCP 22 (SSH), Ingress TCP 80 (HTTP → redirect to HTTPS), Ingress TCP 443 (HTTPS + WSS).

· · ·

15. Post-deployment checklist

  • DNS A record for domain points to the correct server IP
  • systemctl status myapp shows active (running)
  • curl -s https://your-domain.com/health returns HTTP 200
  • certbot renew --dry-run succeeds without errors
  • nginx -t passes with no warnings
  • Firewall allows only ports 22, 80, 443 (ufw status)
  • .env file has chmod 640 and is owned by the app user, not world-readable
  • composer.json does not include dev-only packages in production (--no-dev)
  • GitHub Actions CI badge is green on main
  • First deploy ran successfully from GitHub Actions (not just from the server directly)
· · ·

Cheat sheet — common commands

# Tail live application logs
journalctl -u myapp -f

# Restart the service
sudo systemctl restart myapp

# Reload Nginx without downtime (config changes only)
sudo systemctl reload nginx

# View Nginx error log
tail -f /var/log/nginx/error.log

# Check which process is using a port
ss -tlnp | grep 8081

# Test Nginx config before reloading
nginx -t

# Force-renew SSL certificate
certbot renew --force-renewal --nginx -d your-domain.com

# Check SSL expiry
echo | openssl s_client -connect your-domain.com:443 2>/dev/null \
    | openssl x509 -noout -dates
· · ·

Variables reference

Replace every occurrence of these placeholders with your real values:

Placeholder Example Meaning
myapp logstream App slug — used for user, directory, and service names
your-domain.com sub.domain.com The domain pointing to this server
your-server-ip 152.67.xx.xx Server public IP
your-org/your-repo guibranco/logstream-server GitHub repository
8081 8081 Internal HTTP port your PHP app listens on
8080 8080 Internal WebSocket port (omit section if not used)
bin/server.php bin/server.php Entry point script to start your app