T
Tenanto
Documentation / Hetzner Deployment

Hetzner Deployment

Updated Jan 25, 2026

Tenanto Demo Deployment - Hetzner Server

Server: debian-dev (138.199.199.163) Domain: tenanto.dev DNS Provider: Cloudflare


1. Cloudflare DNS Records

Add these DNS records in Cloudflare Dashboard for tenanto.dev:

Type Name Content Proxy TTL
A @ 138.199.199.163 Proxied (orange cloud) Auto
A * 138.199.199.163 Proxied (orange cloud) Auto
A admin 138.199.199.163 Proxied (orange cloud) Auto
A demo 138.199.199.163 Proxied (orange cloud) Auto

Explanation:


2. Cloudflare SSL/TLS Settings

2.1 SSL Mode

Go to SSL/TLSOverview → Set to Full (strict)

2.2 Origin Certificate

Go to SSL/TLSOrigin ServerCreate Certificate

  1. Click Create Certificate
  2. Settings:
    • Private key type: RSA (2048)
    • Hostnames:
      • tenanto.dev
      • *.tenanto.dev
    • Certificate validity: 15 years (recommended)
  3. Click Create
  4. Copy the certificate (Origin Certificate)
  5. Copy the private key
  6. Save both files - you'll need them for the server

3. Server Preparation

3.1 Create Directory Structure

ssh devhetzner
su - claude
cd /home/claude/docker

# Create Tenanto directories
mkdir -p apps/tenanto
mkdir -p volumes/tenanto-postgres
mkdir -p volumes/tenanto-redis
mkdir -p certs/tenanto

3.2 Upload SSL Certificates

Save the Cloudflare Origin Certificate files:


4. Docker Compose for Tenanto

Create /home/claude/docker/apps/tenanto/docker-compose.yml:

services:
  tenanto-app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: tenanto-app
    restart: unless-stopped
    environment:
      APP_NAME: "Tenanto Demo"
      APP_ENV: production
      APP_DEBUG: false
      APP_URL: https://tenanto.dev
      APP_KEY: ${TENANTO_APP_KEY}

      DB_CONNECTION: pgsql
      DB_HOST: tenanto-postgres
      DB_PORT: 5432
      DB_DATABASE: tenanto
      DB_USERNAME: tenanto
      DB_PASSWORD: ${TENANTO_DB_PASSWORD}

      REDIS_HOST: tenanto-redis
      REDIS_PASSWORD: ${TENANTO_REDIS_PASSWORD}
      REDIS_PORT: 6379

      CACHE_DRIVER: redis
      QUEUE_CONNECTION: redis
      SESSION_DRIVER: redis

      MAIL_MAILER: log

      # Demo mode
      DEMO_MODE_ENABLED: true
      DEMO_TENANT_MAX_AGE_HOURS: 24
    volumes:
      - ./:/var/www/html
      - tenanto-storage:/var/www/html/storage/app
    networks:
      - docker_docker_network
    depends_on:
      tenanto-postgres:
        condition: service_healthy
      tenanto-redis:
        condition: service_healthy

  tenanto-postgres:
    image: postgres:16-alpine
    container_name: tenanto-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: tenanto
      POSTGRES_USER: tenanto
      POSTGRES_PASSWORD: ${TENANTO_DB_PASSWORD}
    volumes:
      - ../../volumes/tenanto-postgres:/var/lib/postgresql/data
    networks:
      - docker_docker_network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tenanto -d tenanto"]
      interval: 10s
      timeout: 5s
      retries: 5

  tenanto-redis:
    image: redis:7-alpine
    container_name: tenanto-redis
    restart: unless-stopped
    command: redis-server --requirepass ${TENANTO_REDIS_PASSWORD}
    volumes:
      - ../../volumes/tenanto-redis:/data
    networks:
      - docker_docker_network
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${TENANTO_REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  tenanto-queue:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: tenanto-queue
    restart: unless-stopped
    command: php artisan queue:work --sleep=3 --tries=3
    environment:
      APP_ENV: production
      DB_CONNECTION: pgsql
      DB_HOST: tenanto-postgres
      DB_PORT: 5432
      DB_DATABASE: tenanto
      DB_USERNAME: tenanto
      DB_PASSWORD: ${TENANTO_DB_PASSWORD}
      REDIS_HOST: tenanto-redis
      REDIS_PASSWORD: ${TENANTO_REDIS_PASSWORD}
    volumes:
      - ./:/var/www/html
      - tenanto-storage:/var/www/html/storage/app
    networks:
      - docker_docker_network
    depends_on:
      - tenanto-app

  tenanto-scheduler:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: tenanto-scheduler
    restart: unless-stopped
    command: sh -c "while true; do php artisan schedule:run --verbose --no-interaction & sleep 60; done"
    environment:
      APP_ENV: production
      DB_CONNECTION: pgsql
      DB_HOST: tenanto-postgres
      DB_PORT: 5432
      DB_DATABASE: tenanto
      DB_USERNAME: tenanto
      DB_PASSWORD: ${TENANTO_DB_PASSWORD}
      REDIS_HOST: tenanto-redis
      REDIS_PASSWORD: ${TENANTO_REDIS_PASSWORD}
    volumes:
      - ./:/var/www/html
    networks:
      - docker_docker_network
    depends_on:
      - tenanto-app

volumes:
  tenanto-storage:

networks:
  docker_docker_network:
    external: true

5. Dockerfile for PHP 8.4

Create /home/claude/docker/apps/tenanto/Dockerfile:

FROM php:8.4-fpm-alpine

# Install system dependencies
RUN apk add --no-cache \
    git \
    curl \
    libpng-dev \
    libxml2-dev \
    zip \
    unzip \
    postgresql-dev \
    icu-dev \
    linux-headers \
    oniguruma-dev \
    freetype-dev \
    libjpeg-turbo-dev \
    nodejs \
    npm

# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) \
        pdo \
        pdo_pgsql \
        pgsql \
        mbstring \
        exif \
        pcntl \
        bcmath \
        gd \
        intl \
        opcache \
        xml

# Install Redis extension
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apk del .build-deps

# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Set working directory
WORKDIR /var/www/html

# Copy application
COPY . .

# Install dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction

# Build frontend assets
RUN npm ci && npm run build && rm -rf node_modules

# Set permissions
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache

# PHP configuration
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.memory_consumption=128" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.interned_strings_buffer=8" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.max_accelerated_files=10000" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini

EXPOSE 9000

CMD ["php-fpm"]

6. Nginx Configuration

Create /home/claude/docker/services/nginx/sites-enabled/tenanto.dev.conf:

# Tenanto Demo - Multi-tenant SaaS
# Handles: tenanto.dev, admin.tenanto.dev, *.tenanto.dev

# HTTP to HTTPS redirect
server {
    listen 80;
    server_name tenanto.dev *.tenanto.dev;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# Main landing page (tenanto.dev)
server {
    listen 443 ssl;
    http2 on;
    server_name tenanto.dev;

    root /var/www/apps/tenanto/public;
    index index.php;

    # SSL Configuration (Cloudflare Origin Certificate)
    ssl_certificate /etc/cloudflare/tenanto/origin-cert.pem;
    ssl_certificate_key /etc/cloudflare/tenanto/origin-key.pem;

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

    # Logging
    access_log /var/log/nginx/tenanto.dev-access.log;
    error_log /var/log/nginx/tenanto.dev-error.log;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass tenanto-app:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\. {
        deny all;
    }
}

# Admin panel (admin.tenanto.dev)
server {
    listen 443 ssl;
    http2 on;
    server_name admin.tenanto.dev;

    root /var/www/apps/tenanto/public;
    index index.php;

    ssl_certificate /etc/cloudflare/tenanto/origin-cert.pem;
    ssl_certificate_key /etc/cloudflare/tenanto/origin-key.pem;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'self';" always;

    access_log /var/log/nginx/admin.tenanto.dev-access.log;
    error_log /var/log/nginx/admin.tenanto.dev-error.log;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass tenanto-app:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\. {
        deny all;
    }
}

# Tenant subdomains (*.tenanto.dev)
server {
    listen 443 ssl;
    http2 on;
    server_name ~^(?<subdomain>.+)\.tenanto\.dev$;

    root /var/www/apps/tenanto/public;
    index index.php;

    ssl_certificate /etc/cloudflare/tenanto/origin-cert.pem;
    ssl_certificate_key /etc/cloudflare/tenanto/origin-key.pem;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'self';" always;

    access_log /var/log/nginx/tenant.tenanto.dev-access.log;
    error_log /var/log/nginx/tenant.tenanto.dev-error.log;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass tenanto-app:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    # Static assets caching
    location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    location ~ /\. {
        deny all;
    }
}

7. Environment Variables

Add to /home/claude/docker/.env:

# Tenanto Demo
TENANTO_APP_KEY=base64:GENERATE_NEW_KEY_HERE
TENANTO_DB_PASSWORD=GENERATE_SECURE_PASSWORD
TENANTO_REDIS_PASSWORD=GENERATE_SECURE_PASSWORD

Generate secure values:

# Generate APP_KEY
php artisan key:generate --show

# Generate passwords
openssl rand -base64 32

8. Deployment Steps

Step 1: Configure Cloudflare

  1. Add DNS records (see section 1)
  2. Set SSL mode to Full (strict)
  3. Create Origin Certificate (see section 2.2)

Step 2: Prepare Server

ssh devhetzner
su - claude
cd /home/claude/docker

# Create directories
mkdir -p apps/tenanto
mkdir -p volumes/tenanto-postgres
mkdir -p volumes/tenanto-redis
mkdir -p certs/tenanto

# Upload Origin Certificate (from local machine)
# scp origin-cert.pem devhetzner:/home/claude/docker/certs/tenanto/
# scp origin-key.pem devhetzner:/home/claude/docker/certs/tenanto/

# Set permissions
chmod 600 certs/tenanto/*

Step 3: Deploy Application

cd /home/claude/docker/apps/tenanto

# Clone or copy the application
# Option A: From Git
git clone https://gitlab.com/mazacmartin/tenanto.dev.git .

# Option B: Upload ZIP
# scp tenanto.zip devhetzner:/home/claude/docker/apps/tenanto/
# unzip tenanto.zip

# Create .env
cp .env.example .env
# Edit .env with production values

# Start containers
docker compose up -d

# Run migrations
docker compose exec tenanto-app php artisan migrate --seed

# Generate demo tenant
docker compose exec tenanto-app php artisan demo:create

Step 4: Configure Nginx

# Copy nginx config
# Already created in step 6

# Reload nginx
docker exec nginx-proxy nginx -t
docker exec nginx-proxy nginx -s reload

Step 5: Verify


9. Maintenance Commands

# View logs
docker compose logs -f tenanto-app

# Clear caches
docker compose exec tenanto-app php artisan optimize:clear

# Run migrations
docker compose exec tenanto-app php artisan migrate

# Create demo tenant
docker compose exec tenanto-app php artisan demo:create

# Cleanup expired demos
docker compose exec tenanto-app php artisan demo:cleanup

10. Rollback Plan

If something goes wrong:

# Stop Tenanto containers
cd /home/claude/docker/apps/tenanto
docker compose down

# Remove nginx config
rm /home/claude/docker/services/nginx/sites-enabled/tenanto.dev.conf
docker exec nginx-proxy nginx -s reload

# Existing services remain unaffected

Summary

Component Location
Application /home/claude/docker/apps/tenanto/
PostgreSQL data /home/claude/docker/volumes/tenanto-postgres/
Redis data /home/claude/docker/volumes/tenanto-redis/
SSL Certificates /home/claude/docker/certs/tenanto/
Nginx config /home/claude/docker/services/nginx/sites-enabled/tenanto.dev.conf
Logs /home/claude/docker/logs/nginx/tenanto.dev-*.log