Negli ambienti di produzione nativi su server dedicati o VPS (come Hetzner Cloud), la gestione delle notifiche push remote per applicazioni iOS richiede un'architettura backend snella, performante e priva dell'overhead di containerizzazione virtualizzata. Quando un'app iOS viene chiusa dall'utente tramite swipe-up nell'App Switcher, il sistema operativo termina il processo dell'app e l'unico canale al mondo in grado di svegliare il dispositivo è il gateway Apple Push Notification service (APNs) via HTTP/2.
In questa guida esaustiva vediamo come configurare un backend Laravel 11 / PHP 8.3-FPM su VPS Ubuntu 24.04 con Nginx, PostgreSQL 18 e certificati SSL Let's Encrypt per consegnare notifiche push istantanee ad app killata, gestendo il doppio fallback tra ambiente Sandbox e Production ed evitando gli errori di autenticazione token.
1. Ciclo di Vita iOS e il Ruolo del Gateway APNs
Quando sviluppi un'applicazione iOS in SwiftUI, l'app passa attraverso vari stati dell'applicazione:
- Foreground: L'app è attiva sullo schermo. Le notifiche locali e le chiamate API in-app aggiornano l'interfaccia.
- Background: L'app è in sospeso. Se l'utente riceve una notifica push remota con
content-available: 1, iOS risveglia il processo in background per un massimo di 30 secondi. - Terminated / Killed: L'utente ha chiuso l'app con uno swipe verso l'alto. Il processo non esiste in memoria. Solo i server APNs di Apple a Cupertino possono mostrare il banner sulla Lock Screen, riprodurre il suono di sistema e aggiornare il Badge rosso sull'icona.
Non inserire mai la chiave privata .p8 o le credenziali APNs nei repository pubblici. Sul server VPS, la chiave va custodita in /var/www/backend/storage/keys/AuthKey_XXXXX.p8 con permessi 600 ristretti all'utente www-data.
2. Configurazione Nginx e PHP 8.3 FPM su VPS Hetzner
Su una VPS Hetzner Cloud con Ubuntu 24.04 LTS, Nginx deve essere configurato per inoltrare le richieste PHP tramite FastCGI verso la socket Unix di PHP 8.3 FPM. Assicurati che l'estensione php8.3-curl sia abilitata per supportare cURL con HTTP/2 (necessario per negoziare la connessione TLS con i server Apple).
# /etc/nginx/sites-available/api.tuodominio.com
server {
listen 80;
listen [::]:80;
server_name api.tuodominio.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.tuodominio.com;
root /var/www/backend/public;
index index.php;
ssl_certificate /etc/letsencrypt/live/api.tuodominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.tuodominio.com/privkey.pem;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
}
}
3. Generazione Token JWT ES256 in PHP per Apple APNs
Apple richiede l'autenticazione HTTP/2 tramite token JWT firmato con l'algoritmo ES256 (Elliptic Curve Digital Signature Algorithm con curva P-256 e SHA-256). Ecco la classe PHP pura per generare l'header di autorizzazione ed eseguire il cURL verso Apple:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
class ApnsService
{
public static function sendPush(string $deviceToken, string $title, string $body, array $data = []): bool
{
$keyId = env('APNS_KEY_ID');
$teamId = env('APNS_TEAM_ID');
$bundleId = env('APNS_BUNDLE_ID', 'com.tuodominio.app');
$keyPath = env('APNS_PRIVATE_KEY_PATH');
if (!file_exists($keyPath)) {
Log::error("APNs: File chiave privata .p8 non trovato.");
return false;
}
$privateKey = file_get_contents($keyPath);
$jwt = self::generateJwtToken($keyId, $teamId, $privateKey);
$payload = [
'aps' => [
'alert' => [
'title' => $title,
'body' => $body,
],
'badge' => $data['badge'] ?? 1,
'sound' => 'default',
'mutable-content' => 1,
'content-available' => 1,
'category' => 'BiTre_INTERACTION'
]
];
// Fallback automatico tra Sandbox (Debug Xcode) e Production (App Store)
$endpoints = [
"https://api.sandbox.push.apple.com/3/device/{$deviceToken}",
"https://api.push.apple.com/3/device/{$deviceToken}"
];
foreach ($endpoints as $url) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_PORT => 443,
CURLOPT_HTTPHEADER => [
"apns-topic: {$bundleId}",
"apns-push-type: alert",
"apns-priority: 10",
"authorization: bearer {$jwt}",
"content-type: application/json"
],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
Log::info("✅ APNs: Notifica consegnata con successo via {$url}");
return true;
}
}
Log::error("❌ APNs: Consegna fallita su entrambi gli endpoint Apple.");
return false;
}
private static function generateJwtToken(string $keyId, string $teamId, string $privateKey): string
{
$header = json_encode(['alg' => 'ES256', 'kid' => $keyId]);
$claims = json_encode(['iss' => $teamId, 'iat' => time()]);
$base64Header = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($header));
$base64Claims = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($claims));
$dataToSign = "{$base64Header}.{$base64Claims}";
$pkey = openssl_pkey_get_private($privateKey);
openssl_sign($dataToSign, $rawSignature, $pkey, OPENSSL_ALGO_SHA256);
// Conversione della firma DER in formato IEEE P1363 (64 bytes raw R+S)
$signature = self::derToRaw($rawSignature);
return "{$dataToSign}." . str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($signature));
}
private static function derToRaw(string $der): string
{
if (strlen($der) === 64) return $der;
$pos = 0;
if (ord($der[$pos++]) !== 0x30) return $der;
$len = ord($der[$pos++]);
if ($len & 0x80) $pos += ($len & 0x7f);
if (ord($der[$pos++]) !== 0x02) return $der;
$rLen = ord($der[$pos++]);
$r = substr($der, $pos, $rLen);
$pos += $rLen;
if (ord($der[$pos++]) !== 0x02) return $der;
$sLen = ord($der[$pos++]);
$s = substr($der, $pos, $sLen);
$r = ltrim($r, "\x00");
$s = ltrim($s, "\x00");
return str_pad($r, 32, "\x00", STR_PAD_LEFT) . str_pad($s, 32, "\x00", STR_PAD_LEFT);
}
}
4. Gestione lato iOS (SwiftUI & UNUserNotificationCenter)
Lato iOS, l'app deve richiedere l'autorizzazione per le notifiche ed inviare il token APNs esadecimale al server VPS al momento della registrazione o del login:
import SwiftUI
import UserNotifications
@main
struct BiTreApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
if granted {
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
}
}
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
print("📱 APNs Device Token: \(tokenString)")
// Invia il token alla VPS tramite API HTTP POST /api/notifications/device-token
}
}
5. Checklist Risoluzione Problemi Comuni
- Errore HTTP 400 BadDeviceToken: Indica che il token è stato generato in ambiente Sandbox (Xcode Debug) ma stai inviando a
api.push.apple.com(Production), o viceversa. Il codice PHP sopra prova automaticamente entrambi gli endpoint. - Permessi File Log e Storage: Assicurati che
storage/logsebootstrap/cacheappartengano all'utentewww-data:www-datacon permessi775. - Tabella Queue/Jobs: Se accodi le notifiche tramite Laravel Queue, esegui la migrazione
php artisan queue:table && php artisan migrateed impostaQUEUE_CONNECTION=syncnel file.envper l'elaborazione istantanea.
Hai un'idea di App iOS o desideri consulenza tecnica per la tua infrastruttura Cloud VPS?
Sviluppo applicazioni iOS native in SwiftUI, backend Laravel ad alte prestazioni e supporto per monetizzazione internazionale.