Chapter 33: Deployment#
1. From Development to Production#
The app works on localhost:7145. Now it needs to run around the clock on a real server. Handle thousands of concurrent users. Survive restarts. Hold steady on memory. The gap between "works on my machine" and "works in production" is where projects stumble.
This chapter covers everything for a production deployment: environment configuration, Docker packaging, web server setup, SSL/TLS, scaling, monitoring, and graceful shutdown.
When you run tina4 init, the framework generates a production-ready Dockerfile and .dockerignore in your project root. The Dockerfile uses a multi-stage build: the first stage installs Composer dependencies and the second stage copies only the runtime artifacts into a slim image. You do not need to write a Dockerfile from scratch -- the generated one is a solid starting point that you can customise as needed.
2. Production .env Configuration#
Development defaults optimize for debugging. Production defaults optimize for performance and security. The first deployment step: configure .env for production.
Create a production .env:
# CoreTINA4_DEBUG=falseTINA4_LOG_LEVEL=WARNINGTINA4_PORT=7145โ# DatabaseTINA4_DATABASE_URL=sqlite:///data/app.dbโ# SecurityTINA4_CORS_ORIGINS=https://yourdomain.comTINA4_SECRET=your-long-random-secret-at-least-32-charactersTINA4_RATE_LIMIT=120โ# PerformanceTINA4_TEMPLATE_CACHE_TTL=trueโKey Differences from Development#
| Setting | Development | Production | Why |
|---|---|---|---|
TINA4_DEBUG | true | false | Hides stack traces, disables toolbar |
TINA4_LOG_LEVEL | ALL | WARNING | Reduces log noise |
CORS_ORIGINS | * | Your domain | Prevents cross-origin abuse |
Sensitive Values#
Production secrets never go into version control. The .env file is gitignored by default. For deployment, use environment variables from your hosting platform, CI/CD secrets, or a secrets manager.
# Docker: pass env vars at runtimedocker run -e JWT_SECRET=your-secret -e TINA4_DATABASE_URL=sqlite:///data/app.db my-appโ# Fly.io: set secretsfly secrets set JWT_SECRET=your-secretโ# Railway: use the dashboard or CLIrailway variables set JWT_SECRET=your-secret3. FrankenPHP Auto-Detection#
Tina4 PHP auto-detects FrankenPHP at startup. FrankenPHP is a modern PHP application server built on Caddy:
- Worker mode (keeps PHP in memory between requests)
- Built-in HTTPS with automatic certificate management
- HTTP/2 and HTTP/3 support
- Dramatically better performance than PHP-FPM for long-running applications
How Auto-Detection Works#
When you run tina4 serve --production, the CLI checks for FrankenPHP:
- If
frankenphpis in your PATH, it uses FrankenPHP in worker mode - If not, it falls back to PHP's built-in server with production optimizations
# With FrankenPHP installedtina4 serve --production Tina4 PHP v3.0.0 Server: FrankenPHP (worker mode) Workers: 4 Running at https://0.0.0.0:7145 TLS: automatic (Let's Encrypt)# Without FrankenPHPtina4 serve --production Tina4 PHP v3.0.0 Server: PHP built-in Running at http://0.0.0.0:7145 Warning: For production, consider FrankenPHP or PHP-FPM + nginxInstalling FrankenPHP#
# macOSbrew install dunglas/tap/frankenphpโ# Linuxcurl -fsSL https://frankenphp.dev/install.sh | bashโ# Docker (no installation needed)docker pull dunglas/frankenphp4. Docker Deployment#
Docker is the most portable deployment path. Your app runs the same way on your laptop, in CI, and on the production server.
Official Base Image#
Tina4 PHP provides an official Docker Hub base image: tina4stack/tina4-php:v3. It is an Alpine-based image (~154MB) with PHP 8.4, SQLite, OPcache, and the Tina4 framework pre-installed. Your app Dockerfile extends it and adds only your application code and Composer dependencies.
The base image includes these environment variables pre-configured:
TINA4_OVERRIDE_CLIENT=true, bypasses the CLI guard for DockerTINA4_DEBUG=false, production mode by default
OPcache is pre-configured with production settings (timestamps disabled, 10000 accelerated files).
Dockerfile#
Create Dockerfile at the project root:
FROM tina4stack/tina4-php:v3WORKDIR /appโ# Install Composer and app dependenciesCOPY --from=composer:2 /usr/bin/composer /usr/bin/composerCOPY composer.json composer.lock* ./RUN composer install --no-dev --optimize-autoloader --no-scripts \ && rm /usr/bin/composerโ# Copy application codeCOPY index.php .COPY .env .COPY migrations/ migrations/COPY src/ src/โ# Create data directories for SQLite, sessions, queue, and mailboxRUN mkdir -p data data/sessions data/queue data/mailboxโEXPOSE 7145CMD ["php", "index.php", "0.0.0.0:7145"].dockerignore#
Create .dockerignore to keep the image small:
.git.env.developmentdata/logs/secrets/node_modules/vendor/tests/.DS_Store.tina4/Build and Run#
# Build the imagedocker build -t my-tina4-app .โ# Run itdocker run -d \ --name my-app \ -p 7145:7145 \ -e JWT_SECRET=your-production-secret \ -v $(pwd)/data:/app/data \ my-tina4-appVerify#
curl http://localhost:7145/health{ "status": "ok", "database": "connected", "uptime_seconds": 5, "version": "3.0.0", "framework": "tina4-php"}Adding Database Drivers#
The base image ships with SQLite only. To use PostgreSQL, MySQL, MSSQL, or Firebird, install the driver in your Dockerfile using the install-php-extensions tool from mlocati.
PostgreSQL:
FROM tina4stack/tina4-php:v3WORKDIR /appCOPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/bin/RUN install-php-extensions pdo_pgsql && rm /usr/bin/install-php-extensionsCOPY --from=composer:2 /usr/bin/composer /usr/bin/composerCOPY composer.json composer.lock* ./RUN composer install --no-dev --optimize-autoloader --no-scripts && rm /usr/bin/composerCOPY index.php .COPY .env .COPY migrations/ migrations/COPY src/ src/RUN mkdir -p data data/sessions data/queue data/mailboxEXPOSE 7145CMD ["php", "index.php", "0.0.0.0:7145"]MySQL:
FROM tina4stack/tina4-php:v3WORKDIR /appCOPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/bin/RUN install-php-extensions pdo_mysql && rm /usr/bin/install-php-extensionsCOPY --from=composer:2 /usr/bin/composer /usr/bin/composerCOPY composer.json composer.lock* ./RUN composer install --no-dev --optimize-autoloader --no-scripts && rm /usr/bin/composerCOPY index.php .COPY .env .COPY migrations/ migrations/COPY src/ src/RUN mkdir -p data data/sessions data/queue data/mailboxEXPOSE 7145CMD ["php", "index.php", "0.0.0.0:7145"]MSSQL:
FROM tina4stack/tina4-php:v3WORKDIR /appCOPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/bin/RUN install-php-extensions pdo_sqlsrv && rm /usr/bin/install-php-extensionsCOPY --from=composer:2 /usr/bin/composer /usr/bin/composerCOPY composer.json composer.lock* ./RUN composer install --no-dev --optimize-autoloader --no-scripts && rm /usr/bin/composerCOPY index.php .COPY .env .COPY migrations/ migrations/COPY src/ src/RUN mkdir -p data data/sessions data/queue data/mailboxEXPOSE 7145CMD ["php", "index.php", "0.0.0.0:7145"]Firebird:
Firebird requires system-level libraries not available in Alpine. Use a Debian-based PHP image instead:
FROM php:8.4-cli-bookwormWORKDIR /appRUN apt-get update && apt-get install -y --no-install-recommends \ firebird-dev libfbclient2 && \ docker-php-ext-install pdo_firebird interbase && \ apt-get purge -y firebird-dev && apt-get autoremove -y && \ rm -rf /var/lib/apt/lists/*COPY --from=composer:2 /usr/bin/composer /usr/bin/composerCOPY composer.json composer.lock* ./RUN composer install --no-dev --optimize-autoloader --no-scripts && rm /usr/bin/composerCOPY index.php .COPY .env .COPY migrations/ migrations/COPY src/ src/RUN mkdir -p data data/sessions data/queue data/mailboxEXPOSE 7145ENV TINA4_OVERRIDE_CLIENT=trueENV TINA4_DEBUG=falseCMD ["php", "index.php", "0.0.0.0:7145"]Note: The Firebird Dockerfile cannot use
tina4stack/tina4-php:v3because the base image is Alpine and Firebird'sfbclientlibrary requires glibc (Debian).
Docker Compose#
Create docker-compose.yml:
services: app: build: . ports: - "7145:7145" environment: - TINA4_DEBUG=false - TINA4_LOG_LEVEL=WARNING - JWT_SECRET=${JWT_SECRET} - TINA4_DATABASE_URL=sqlite:///data/app.db volumes: - app-data:/app/data restart: unless-stoppedโvolumes: app-data:# Start everythingdocker compose up -dโ# View logsdocker compose logs -f appโ# Stop everythingdocker compose down5. PHP-FPM + nginx Configuration#
If you prefer a traditional setup without Docker, use PHP-FPM with nginx.
PHP-FPM Pool Configuration#
Create /etc/php/8.3/fpm/pool.d/tina4.conf:
[tina4]user = www-datagroup = www-datalisten = /run/php/tina4.socklisten.owner = www-datalisten.group = www-dataโpm = dynamicpm.max_children = 50pm.start_servers = 5pm.min_spare_servers = 5pm.max_spare_servers = 35pm.max_requests = 1000โenv[TINA4_DATABASE_URL] = sqlite:///var/www/tina4-app/data/app.dbenv[JWT_SECRET] = your-production-secretenv[TINA4_DEBUG] = falseenv[TINA4_LOG_LEVEL] = WARNINGnginx Configuration#
Create /etc/nginx/sites-available/tina4:
server { listen 80; server_name yourdomain.com; root /var/www/tina4-app/src/public; index index.php;โ # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;โ # Static files location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 30d; add_header Cache-Control "public, immutable"; try_files $uri =404; }โ # All other requests go to Tina4 location / { try_files $uri $uri/ /index.php?$query_string; }โ # PHP processing location ~ \.php$ { fastcgi_pass unix:/run/php/tina4.sock; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; fastcgi_buffers 16 16k; fastcgi_buffer_size 32k; }โ # Block access to sensitive files location ~ /\.(env|git|htaccess) { deny all; }โ location ~ ^/(data|logs|secrets|vendor)/ { deny all; }}Enable the site and restart:
sudo ln -s /etc/nginx/sites-available/tina4 /etc/nginx/sites-enabled/sudo nginx -tsudo systemctl restart nginxsudo systemctl restart php8.3-fpm6. Health Check Endpoint#
Tina4 includes a built-in health check endpoint at /health:
curl http://localhost:7145/health{ "status": "ok", "database": "connected", "uptime_seconds": 3600, "version": "3.0.0", "framework": "tina4-php"}If the database is disconnected or the application is in a bad state, the health check returns a non-200 status:
{ "status": "error", "database": "disconnected", "error": "Could not connect to database", "uptime_seconds": 3600, "version": "3.0.0", "framework": "tina4-php"}Use this endpoint for:
- Docker HEALTHCHECK (shown in the Dockerfile above)
- Load balancer health checks (AWS ALB, nginx upstream, HAProxy)
- Monitoring tools (Uptime Robot, Pingdom, custom scripts)
- Kubernetes liveness and readiness probes
Custom Health Checks#
You can extend the health check to include your own checks:
<?phpuse Tina4\Router;โRouter::get("/health/detailed", function ($request, $response) { $checks = [ "database" => false, "cache" => false, "disk_space" => false ];โ // Check database try { $product = new Product(); $product->select("count(*) as cnt"); $checks["database"] = true; } catch (\Exception $e) { // Database check failed }โ // Check disk space $freeBytes = disk_free_space("/"); $checks["disk_space"] = $freeBytes > 100 * 1024 * 1024; // 100MB minimumโ $allOk = !in_array(false, $checks);โ return $response->json([ "status" => $allOk ? "ok" : "degraded", "checks" => $checks, "free_disk_mb" => round($freeBytes / 1024 / 1024) ], $allOk ? 200 : 503);});7. Graceful Shutdown#
Deploy a new version. The old process must stop. Graceful shutdown finishes active requests before terminating.
Tina4 handles this when it receives a SIGTERM signal (the standard shutdown signal from Docker and systemd):
- Stop accepting new connections
- Wait for active requests to complete (up to a configurable timeout)
- Close database connections
- Flush logs
- Exit
Shutdown Timeout#
Set the maximum time to wait for in-flight requests in .env:
TINA4_SHUTDOWN_TIMEOUT=30If requests are still processing after 30 seconds, the server force-kills them and exits. The default is 30 seconds, which is enough for most applications.
Docker Stop Behavior#
Docker sends SIGTERM, waits 10 seconds (by default), then sends SIGKILL. Match the Docker timeout to your shutdown timeout:
services: app: stop_grace_period: 35s # Slightly more than TINA4_SHUTDOWN_TIMEOUT8. Log Rotation#
In production, log files grow without limit unless rotated. Tina4 writes to logs/app.log.
Using logrotate (Linux)#
Create /etc/logrotate.d/tina4:
/var/www/tina4-app/logs/*.log { daily rotate 14 compress delaycompress missingok notifempty create 640 www-data www-data postrotate # Tina4 re-opens log files automatically endscript}This rotates logs daily, keeps 14 days of compressed history, and creates new log files with the correct permissions.
Docker Logging#
In Docker, application logs go to stdout/stderr by default. Configure Docker's logging driver to manage rotation:
services: app: logging: driver: "json-file" options: max-size: "10m" max-file: "5"This keeps a maximum of 50MB of logs (5 files of 10MB each).
9. Environment-Specific Configuration#
Use different .env files for different environments:
.env # Development (gitignored).env.example # Template (committed to git).env.staging # Staging config (gitignored).env.production # Production config (gitignored)Loading a Specific .env File#
Set the TINA4_ENV_FILE environment variable before starting:
# StagingTINA4_ENV_FILE=.env.staging tina4 serveโ# ProductionTINA4_ENV_FILE=.env.production tina4 serve --productionIn Docker, pass environment variables directly:
docker run -e TINA4_DEBUG=false -e JWT_SECRET=xxx my-appEnvironment variables set via docker run -e or docker-compose environment: take precedence over .env file values. This lets you use a generic .env file in the image and override specific values at runtime.
10. SSL/TLS with Let's Encrypt#
With FrankenPHP (Automatic)#
FrankenPHP handles SSL automatically. Just set your domain:
TINA4_HOST=yourdomain.comFrankenPHP uses Let's Encrypt to provision and renew certificates automatically. No manual configuration needed.
With nginx (Manual)#
Install Certbot:
sudo apt install certbot python3-certbot-nginxObtain a certificate:
sudo certbot --nginx -d yourdomain.comCertbot modifies your nginx configuration to include SSL settings and sets up automatic renewal. Verify auto-renewal:
sudo certbot renew --dry-runWith Docker (Using a Reverse Proxy)#
Use Traefik or Caddy as a reverse proxy in front of your Tina4 container:
version: "3.8"โservices: reverse-proxy: image: traefik:v3.0 command: - "--providers.docker=true" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" - "--certificatesresolvers.letsencrypt.acme.email=you@example.com" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - letsencrypt:/letsencryptโ app: build: . labels: - "traefik.http.routers.app.rule=Host(`yourdomain.com`)" - "traefik.http.routers.app.tls=true" - "traefik.http.routers.app.tls.certresolver=letsencrypt" environment: - TINA4_DEBUG=false - JWT_SECRET=${JWT_SECRET}โvolumes: letsencrypt:11. Scaling#
Multiple Workers#
FrankenPHP runs multiple worker threads by default. Configure the count in .env:
A good starting point is the number of CPU cores on your server. For I/O-heavy applications (database queries, API calls), you can go higher -- 2x to 4x the core count.
Load Balancing with nginx#
If you run multiple Tina4 instances, use nginx as a load balancer:
upstream tina4_backend { server 127.0.0.1:7145; server 127.0.0.1:7145; server 127.0.0.1:7147; server 127.0.0.1:7148;}โserver { listen 80; server_name yourdomain.com;โ location / { proxy_pass http://tina4_backend; 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; }}Start four instances on different ports:
TINA4_PORT=7145 tina4 serve --production &TINA4_PORT=7145 tina4 serve --production &TINA4_PORT=7147 tina4 serve --production &TINA4_PORT=7148 tina4 serve --production &Docker Scaling#
With Docker Compose, scale horizontally:
docker compose up -d --scale app=4Use a load balancer (Traefik, nginx, or a cloud load balancer) in front of the containers.
12. Monitoring#
Log Aggregation#
In production, send logs to a centralized service:
TINA4_LOG_FORMAT=jsonJSON-formatted logs can be ingested by services like:
- Elastic Stack (ELK)
- Grafana Loki
- Datadog
- AWS CloudWatch
Example JSON log entry:
{ "timestamp": "2026-03-22T14:30:00.123Z", "level": "WARNING", "message": "Rate limit exceeded", "request_id": "req-abc123", "ip": "203.0.113.42", "path": "/api/products", "method": "GET"}Uptime Monitoring#
Point an external monitoring service at your health endpoint:
https://yourdomain.com/healthServices like Uptime Robot, Pingdom, or Better Uptime will ping this endpoint every 30-60 seconds and alert you if it stops responding or returns a non-200 status.
Application Performance Monitoring (APM)#
For deeper insights, add APM instrumentation. Since Tina4 is standard PHP, any PHP APM agent works:
- New Relic APM
- Datadog APM
- Elastic APM
These agents track request latency, database query performance, error rates, and memory usage automatically.
13. Exercise: Deploy a Tina4 PHP App with Docker#
Deploy the task management application you have been building throughout this book.
Requirements#
- Create a
Dockerfilethat:- Uses
tina4stack/tina4-php:v3as the base image - Installs Composer dependencies
- Copies the application code
- Creates data directories
- Exposes port 7145
- Uses
- Create a
docker-compose.ymlthat:- Builds and runs the application
- Maps port 7145
- Uses environment variables for secrets
- Persists the database via volumes
- Includes restart policy
- Create a production
.env.productionwith:- Debug mode off
- Warning-level logging
- Template caching enabled
- Appropriate CORS settings
- Build and run the container.
- Verify the health check works.
- Verify you can create and list products through the containerized API.
Test with:#
# Builddocker compose buildโ# Startdocker compose up -dโ# Health checkcurl http://localhost:7145/healthโ# Create a productcurl -X POST http://localhost:7145/api/products \ -H "Content-Type: application/json" \ -d '{"name": "Docker Widget", "price": 19.99}'โ# List productscurl http://localhost:7145/api/productsโ# View logsdocker compose logs appโ# Stopdocker compose down14. Solution#
Dockerfile#
FROM tina4stack/tina4-php:v3WORKDIR /appโCOPY --from=composer:2 /usr/bin/composer /usr/bin/composerCOPY composer.json composer.lock* ./RUN composer install --no-dev --optimize-autoloader --no-scripts \ && rm /usr/bin/composerโCOPY index.php .COPY .env .COPY migrations/ migrations/COPY src/ src/โRUN mkdir -p data data/sessions data/queue data/mailboxโEXPOSE 7145CMD ["php", "index.php", "0.0.0.0:7145"]docker-compose.yml#
services: app: build: . ports: - "7145:7145" environment: - TINA4_DEBUG=false - TINA4_LOG_LEVEL=WARNING - TINA4_TEMPLATE_CACHE_TTL=true - JWT_SECRET=change-this-to-a-real-secret - TINA4_DATABASE_URL=sqlite:///data/app.db - CORS_ORIGINS=http://localhost:7145 volumes: - app-data:/app/data restart: unless-stopped stop_grace_period: 35sโvolumes: app-data:.env.production#
TINA4_DEBUG=falseTINA4_LOG_LEVEL=WARNINGTINA4_TEMPLATE_CACHE_TTL=trueTINA4_RATE_LIMIT=120TINA4_CORS_ORIGINS=https://yourdomain.comExpected output for health check:
{ "status": "ok", "database": "connected", "uptime_seconds": 8, "version": "3.0.0", "framework": "tina4-php"}Expected output for creating a product:
{ "id": 1, "name": "Docker Widget", "price": 19.99, "in_stock": true, "created_at": "2026-03-22 14:30:00", "updated_at": "2026-03-22 14:30:00"}15. Gotchas#
1. SQLite Concurrency in Production#
Problem: Under high load, SQLite throws "database is locked" errors.
Cause: SQLite uses file-level locking. Multiple concurrent writes block each other.
Fix: For low-to-medium traffic (under 100 concurrent users), SQLite works fine. For higher traffic, switch to PostgreSQL or MySQL:
TINA4_DATABASE_URL=postgres://user:pass@localhost:5432/myappIf you must use SQLite under load, enable WAL mode by adding this to your application startup:
$db = Tina4\Database::getConnection();$db->exec("PRAGMA journal_mode=WAL");2. Data Volume Not Persisted#
Problem: You restart the Docker container and the database is empty.
Cause: The data/ directory is inside the container. When the container is recreated, the data is lost.
Fix: Use a Docker volume to persist the data directory, as shown in the docker-compose.yml above:
volumes: - app-data:/app/data3. .env File Not Loaded in Docker#
Problem: Environment variables from .env are not available in the container.
Cause: Docker does not automatically load .env files inside the container. The .env file is for the host machine (used by docker compose).
Fix: Pass environment variables via the environment: section in docker-compose.yml, or use env_file::
services: app: env_file: - .env.production4. Permission Denied on data/ Directory#
Problem: The application cannot write to data/ or logs/ inside the container.
Cause: The directories were created by root during the Docker build, but the application runs as www-data.
Fix: Add chown to your Dockerfile:
RUN mkdir -p data logs secrets \ && chown -R www-data:www-data data logs secrets5. Health Check Fails During Startup#
Problem: The container restarts in a loop because the health check fails before the application is ready.
Cause: The health check starts immediately, but the application needs a few seconds to initialize.
Fix: Use start_period in the health check to give the application time to start:
HEALTHCHECK --start-period=10s --interval=30s --timeout=5s --retries=3 \ CMD curl -f http://localhost:7145/health || exit 1The start-period tells Docker to ignore health check failures during the first 10 seconds.
6. CORS Errors in Production#
Problem: Your frontend gets "No 'Access-Control-Allow-Origin' header" errors.
Cause: CORS_ORIGINS is set to * in development but to a specific domain in production. If your frontend is served from a different domain or port, CORS blocks the requests.
Fix: Set CORS_ORIGINS to include all domains that need access:
TINA4_CORS_ORIGINS=https://yourdomain.com,https://admin.yourdomain.com7. SSL Certificate Not Renewing#
Problem: Your HTTPS certificate expires and the site goes down.
Cause: The auto-renewal process (Certbot or FrankenPHP) failed silently. Common reasons: DNS changes, firewall blocking port 80 (needed for ACME challenge), or the renewal service crashed.
Fix: Set up monitoring for certificate expiry. Use a tool like ssl-cert-check or an online service that alerts you before the certificate expires. Check renewal logs:
# Certbotsudo certbot renew --dry-runโ# Check crontab for renewalsudo systemctl list-timers | grep certbotFor FrankenPHP, ensure port 80 and 443 are accessible from the internet for ACME challenges.