symfony-opcua · master
Docs · Session manager

Production supervisor

Production-grade orchestration — systemd, Supervisor, Docker. Plus how the daemon coexists with Messenger workers, FrankenPHP, and your deploy script.

The daemon is a long-running PHP process. It needs:

  • A supervisor to keep it up.
  • Log rotation.
  • A deploy hook that restarts it on code updates.

Two recommended supervisors: systemd (the OS-level choice) and Supervisor (the convention Symfony Messenger docs typically reach for).

/etc/systemd/system/opcua-session-manager.service:

text systemd unit
[Unit]
Description=OPC UA Session Manager (Symfony)
After=network-online.target redis-server.service
Wants=network-online.target

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/html
Environment="APP_ENV=prod"
Environment="APP_DEBUG=0"
EnvironmentFile=/etc/opcua/symfony.env
ExecStartPre=/usr/bin/mkdir -p /var/run/opcua
ExecStartPre=/usr/bin/chown www-data:www-data /var/run/opcua
ExecStart=/usr/bin/php /var/www/html/bin/console opcua:session

Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=30

# Hardening
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadWritePaths=/var/run/opcua /var/log /var/www/html/var

[Install]
WantedBy=multi-user.target

Apply:

bash terminal
sudo systemctl daemon-reload
sudo systemctl enable opcua-session-manager
sudo systemctl start opcua-session-manager
sudo systemctl status opcua-session-manager
sudo journalctl -u opcua-session-manager -f

Critical settings:

  • KillSignal=SIGTERM + TimeoutStopSec=30 — graceful shutdown.
  • Restart=on-failure + RestartSec=5 — recover from crashes.
  • EnvironmentFile= — load secrets without exposing them in the unit file.
  • ReadWritePaths= — minimal filesystem access (drop the hardening if it bites you, but keep it as a goal).

Supervisor — Symfony Messenger pattern

If you already run Messenger workers under Supervisor, the daemon fits the same convention.

/etc/supervisor/conf.d/opcua-session-manager.conf:

text supervisor
[program:opcua-session-manager]
process_name=%(program_name)s
command=php /var/www/html/bin/console opcua:session
environment=APP_ENV=prod,APP_DEBUG=0
directory=/var/www/html
autostart=true
autorestart=true
startretries=10
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/supervisor/opcua-session-manager.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10
stopwaitsecs=30
stopsignal=TERM

Apply:

bash terminal
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start opcua-session-manager
sudo supervisorctl status

Use systemd or Supervisor, not both.

Secrets injection

For auth_token and OPC UA passwords:

Option A — EnvironmentFile (systemd)

/etc/opcua/symfony.env (mode 0600, root-owned):

bash EnvironmentFile
OPCUA_AUTH_TOKEN=abc123def456...
OPCUA_PASSWORD=...
DATABASE_URL=...

Referenced in the unit:

text unit
EnvironmentFile=/etc/opcua/symfony.env

Option B — Symfony secrets vault

Bake secrets into config/secrets/<env>/ and ship them. Decrypt on the host with the production key. The Symfony command picks them up automatically.

Choose one approach per project — don't mix.

Log rotation

Two log surfaces:

  1. Supervisor stdout — Supervisor rotates with stdout_logfile_maxbytes / stdout_logfile_backups.
  2. Monolog channel — Symfony's Monolog handler rotates via rotating_file:
text monolog rotation
monolog:
    handlers:
        opcua:
            type: rotating_file
            path: '%kernel.logs_dir%/opcua.log'
            max_files: 14
            level: info
            channels: ['opcua']

For systemd, journald rotates by default — set SystemMaxUse= in /etc/systemd/journald.conf to bound disk use.

Don't double-rotate (Supervisor + Monolog + logrotate). Pick one.

Re-subscribe on restart

If you use auto-publish with imperative subscriptions, register a startup hook:

text systemd ExecStartPost
ExecStartPost=/usr/bin/php /var/www/html/bin/console app:opcua:resubscribe

app:opcua:resubscribe is your own command — see Auto-publish.

For declarative subscriptions (auto_connect: true + subscriptions: in YAML), no extra hook needed — the daemon re-creates them at boot.

Deploy hook

Add a daemon restart to your deploy script after the code update:

Symfony Cloud / Platform.sh / Web hosting with deploy hooks

bash deploy hook
sudo systemctl restart opcua-session-manager

Symfony Deployer

text deploy.php
task('opcua:restart', function () {
    run('sudo systemctl restart opcua-session-manager');
});

after('deploy:cleanup', 'opcua:restart');

Capistrano-style

bash deploy hook
{{release_path}}/bin/console opcua:session-restart

(Where opcua:session-restart is a wrapper command that calls Process::run('systemctl restart ...').)

Why restart?

PHP doesn't auto-reload on file changes. A long-running daemon keeps the old code in memory until restart.

For zero-downtime, run two daemons on different sockets, switch traffic at the config layer. Most plant deployments accept a 5-30 s blip.

Coexistence with Messenger

The daemon is independent of Messenger's worker supervision. They coexist:

Supervisor Manages
systemd OPC UA daemon, Messenger workers
Supervisor Same — different programs, same supervisor

For Messenger workers consuming OPC UA-derived messages:

text Messenger worker unit
[Unit]
Description=Symfony Messenger Worker (opcua-data)
After=opcua-session-manager.service

[Service]
Type=simple
User=www-data
ExecStart=/usr/bin/php /var/www/html/bin/console messenger:consume async_opcua \
    --time-limit=3600 --memory-limit=512M

Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

The After= directive ensures the daemon starts first — but both can run independently. Failed daemon doesn't crash workers (they fall back to direct mode).

Coexistence with FrankenPHP / Caddy

FrankenPHP is just another PHP runtime — same daemon, same client behaviour:

text FrankenPHP unit (sketch)
[Service]
ExecStart=/usr/local/bin/frankenphp run --config /etc/frankenphp/Caddyfile
EnvironmentFile=/etc/opcua/symfony.env

The OPC UA daemon doesn't need to know about FrankenPHP.

Docker

For containerised deployments, run the daemon in its own container:

text docker-compose.yml (snippet)
services:
  app:
    image: my-symfony-app:latest
    # ...

  opcua-daemon:
    image: my-symfony-app:latest
    command: php bin/console opcua:session
    user: www-data
    volumes:
      - opcua-sockets:/var/run/opcua
    restart: unless-stopped
    environment:
      APP_ENV: prod
      OPCUA_SOCKET_PATH: /var/run/opcua/sessions.sock
      OPCUA_AUTH_TOKEN: ${OPCUA_AUTH_TOKEN}

  messenger:
    image: my-symfony-app:latest
    command: php bin/console messenger:consume async_opcua --time-limit=3600
    volumes:
      - opcua-sockets:/var/run/opcua    # share the socket
    environment:
      OPCUA_SOCKET_PATH: /var/run/opcua/sessions.sock

volumes:
  opcua-sockets:

See Recipes · Production deployment.

Health checks

For a quick liveness probe, check whether the daemon's Unix socket file exists:

bash terminal — probe
test -S /var/run/opcua/sessions.sock
echo $?

0 = the socket exists (and is a socket). This matches what OpcuaManager::isSessionManagerRunning() does — a passive file_exists check, not an active ping. For a deeper probe, exchange a real opcua-session-manager IPC envelope (length- prefixed JSON with {command, sessionId, method, params, authToken}); the matching response is {success, data, error}. See opcua-session-manager's envelope-and-framing docs for the wire format.

Wire into your monitoring — Datadog, Prometheus blackbox, etc. See Monitoring the daemon.

Resource limits

LimitNOFILE= for file descriptors under load:

text limits
[Service]
LimitNOFILE=65536

Memory budget: 100-300 MB for typical workloads.

Documentation