Release update — root marketing site & reliable startup. The public marketing site is now the root (/). Sign-in is /login.php; the dashboard is /dashboard.php (login required). New: API base /api/v1, graceful DB/config error pages, and the “Database.php line 41” fix. See the sections added at the end of this guide.
Operations Documentation

BlockID Portal — Deployment Guide

A practical guide for system administrators deploying the BlockID Portal: a blockchain-style identity and verifiable-credential management application built on PHP 8.2+ and MySQL 8+. This document covers server requirements, installation, hardening, backups, upgrades, and verification.

1. Overview & Architecture

BlockID Portal is a server-rendered PHP application backed by a single MySQL database. There is no client-side framework, no build step, and no third-party package manager: the codebase is pure PHP using PDO with prepared statements throughout. All HTML is generated on the server; the browser receives plain markup and static assets.

The application is organized around a project root that contains the front controller and the functional areas of the app:

The "ledger" is the application's audit backbone. It is an append-only, hash-chained set of rows in MySQL: each block stores a hash of its payload plus the hash of the previous block, forming a tamper-evident chain. This makes unauthorized after-the-fact edits detectable, but it is not a distributed blockchain — there is no consensus, no peers, and no proof-of-work. It is best described as tamper-evident, not tamper-proof. Administrators validate the chain on demand using the "Verify Ledger Integrity" button on the Ledger page.

Note Because the entire app is server-rendered with no external dependencies, deployment is mostly a matter of (1) pointing a PHP-capable web server at the project root, (2) creating a MySQL database and user, and (3) running the web installer once.

2. Server Requirements

ComponentRequirement
Operating systemAny modern Linux (Debian/Ubuntu, RHEL/Rocky/Alma) or a comparable Unix-like host. Windows Server with PHP is possible but not the target.
Web serverApache 2.4+ (with mod_rewrite and mod_headers) or Nginx with PHP-FPM.
PHP8.2 or newer. Required extensions: pdo_mysql, openssl, mbstring, and json (bundled in PHP 8). opcache is strongly recommended in production.
DatabaseMySQL 8.0 or newer (a compatible MariaDB may work but is not the reference target). The schema uses utf8mb4.
TLSA valid certificate is required for production. The app sets secure cookies and assumes it is served over HTTPS.
ToolingThe mysql / mysqldump client utilities for backups and maintenance. No Composer, no npm, no CDN, no frameworks.

You can confirm the loaded PHP extensions quickly:

php -v
php -m | grep -Ei 'pdo_mysql|openssl|mbstring|json|opcache'

3. PHP Configuration

Tune php.ini (or a dedicated drop-in such as /etc/php/8.2/fpm/conf.d/99-blockid.ini) for production. The goal is to hide internal errors from visitors, log them privately, and keep the runtime fast and locked down.

; --- Error handling (production) ---
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/blockid/php-error.log     ; outside the web root

; --- Hide PHP from response headers ---
expose_php = Off

; --- Sessions: secure cookies ---
session.cookie_secure = 1        ; HTTPS only
session.cookie_httponly = 1      ; no JS access
session.cookie_samesite = "Lax"  ; or "Strict" if no cross-site flows
session.use_strict_mode = 1

; --- Resources ---
memory_limit = 256M
upload_max_filesize = 8M
post_max_size = 12M
max_execution_time = 30

; --- OPcache (production) ---
opcache.enable = 1
opcache.validate_timestamps = 0   ; reload manually on deploy (see Upgrades)
opcache.memory_consumption = 128
opcache.max_accelerated_files = 10000
Note The error_log path must live outside the document root (e.g. /var/log/blockid/) and be writable by the PHP process. Never log to a file inside the project where it could be served to the public. The app's own APP_ENV=production setting separately suppresses stack traces in the UI — see Hardening.
Caution Setting opcache.validate_timestamps = 0 means PHP will not notice changed files. This is great for performance but you must reload PHP-FPM (or restart Apache) after every code deploy, or your changes will not take effect.

4. MySQL Setup

Create a dedicated database using the utf8mb4 character set and a least-privilege application user that can only touch the app schema. Run the following as an administrative MySQL account:

-- Database
CREATE DATABASE blockid
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

-- Application user (use a long, random password)
CREATE USER 'blockid'@'10.0.0.5'
  IDENTIFIED BY 'CHANGE_ME_to_a_strong_random_password';

-- Privileges (see note about install-time DDL below)
GRANT SELECT, INSERT, UPDATE, DELETE,
      CREATE, ALTER, INDEX, REFERENCES, DROP
  ON blockid.* TO 'blockid'@'10.0.0.5';

FLUSH PRIVILEGES;

Restrict the user's host ('blockid'@'10.0.0.5' in the example) to the application server's address rather than '%'. If the database lives on the same host as the app, use 'blockid'@'localhost'.

Install-time privileges The web installer creates tables and indexes, so during installation the app user needs DDL rights: CREATE, ALTER, INDEX, and REFERENCES (the grant above includes them). After installation you may optionally tighten the account down to just SELECT, INSERT, UPDATE, DELETE for normal operation, then temporarily restore DDL rights only when applying schema migrations during an upgrade.
-- OPTIONAL: reduce privileges after a successful install
REVOKE CREATE, ALTER, INDEX, REFERENCES, DROP ON blockid.* FROM 'blockid'@'10.0.0.5';
FLUSH PRIVILEGES;

5. Apache Deployment

Point the DocumentRoot at the project root (the folder containing index.php), not at a public/ subdirectory. The repository ships a root .htaccess that performs URL rewriting, denies the sensitive paths, and forwards the Authorization header to PHP — so AllowOverride All must be set for that file to take effect. Enable the required modules first:

sudo a2enmod rewrite headers ssl
sudo systemctl reload apache2

A representative TLS virtual host:

<VirtualHost *:443>
    ServerName portal.example.com
    DocumentRoot /var/www/blockid          # project root (contains index.php)

    SSLEngine on
    SSLCertificateFile      /etc/letsencrypt/live/portal.example.com/fullchain.pem
    SSLCertificateKeyFile   /etc/letsencrypt/live/portal.example.com/privkey.pem

    <Directory /var/www/blockid>
        AllowOverride All                  # required so the shipped .htaccess applies
        Require all granted
        Options -Indexes -MultiViews       # no directory listings
    </Directory>

    ServerSignature Off
    ErrorLog  /var/log/blockid/apache-error.log
    CustomLog /var/log/blockid/apache-access.log combined
</VirtualHost>

# Redirect plain HTTP to HTTPS
<VirtualHost *:80>
    ServerName portal.example.com
    Redirect permanent / https://portal.example.com/
</VirtualHost>
Shipped .htaccess Do not hand-write deny rules — the repository's root .htaccess already denies /config, /core, /storage, and files matching *.sql, *.md, *.sample, and config.example.php, and it forwards the Authorization header. Just make sure AllowOverride All is in effect so Apache reads it.

6. Nginx Deployment

Nginx does not read .htaccess, so the repository ships nginx.conf.sample with the equivalent rules. Use it as the basis for your server block. It sets the document root to the project root, routes PHP to PHP-FPM, forwards HTTP_AUTHORIZATION so credential verification works, and contains the location blocks that deny the sensitive paths.

server {
    listen 443 ssl;
    server_name portal.example.com;
    root /var/www/blockid;                 # project root (contains index.php)
    index index.php;

    ssl_certificate     /etc/letsencrypt/live/portal.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/portal.example.com/privkey.pem;

    # Deny sensitive directories and file types (mirrors the shipped sample)
    location ~ ^/(config|core|storage)/ { deny all; return 403; }
    location ~* \.(sql|md|sample)$       { deny all; return 403; }
    location = /config.example.php       { deny all; return 403; }

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

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        # Forward the Authorization header to PHP (required for verification)
        fastcgi_param HTTP_AUTHORIZATION $http_authorization;
    }

    server_tokens off;                     # hide Nginx version
}

server {
    listen 80;
    server_name portal.example.com;
    return 301 https://$host$request_uri;
}
Note Always start from the shipped nginx.conf.sample rather than copying the snippet above verbatim — the sample is kept in sync with the application and is the authoritative source for the deny rules.

7. HTTPS / TLS

Production deployments must run over HTTPS. Obtain a certificate from your organization's CA or a provider such as Let's Encrypt (conceptually: issue a cert for portal.example.com, install the full chain and private key, and renew it automatically). Then:

  1. Force HTTP → HTTPS. Redirect all port-80 traffic to port 443 (see the Apache and Nginx examples above).
  2. Set COOKIE_SECURE=true in config/config.php and session.cookie_secure = 1 in PHP so session cookies are only sent over TLS.
  3. Enable HSTS only once you are fully on HTTPS with a valid certificate. Premature HSTS can lock users out if anything is still served over plain HTTP.
# Apache (in the :443 vhost), or Nginx via add_header
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Caution Do not enable HSTS while testing over plain HTTP or with a self-signed certificate. Browsers cache the policy, and a bad certificate after HSTS is enabled will make the site unreachable until the policy expires.

8. Installation Steps

The application self-installs through a one-time web installer. Follow these steps in order:

  1. Deploy the project files to the server (e.g. /var/www/blockid) and set ownership and permissions per section 9.
  2. Configure the web server with the document root at the project root and reload it (sections 5–6).
  3. Create the MySQL database and least-privilege user (section 4), keeping DDL rights for the install.
  4. In a browser, visit https://portal.example.com/install/index.php.
  5. Fill in the installer form: database credentials, the application URL, and the details for the first super administrator.
  6. The installer writes config/config.php from the config/config.example.php template (generating APP_KEY, a base64-encoded 32-byte AES key), loads install/schema.sql, and — if you opt in — install/seed.sql. It then creates the first organization, the super admin account, and the genesis ledger block.
  7. Delete the /install directory (see the warning box below).
  8. Optional: load additional demo data with php install/seed.php --confirm (only meaningful before you delete the installer scripts, and only on non-production demos).
Mandatory — delete the installer After a successful install you must remove the installer so it cannot be re-run against your live database:
rm -rf /var/www/blockid/install
Leaving /install in place is a critical security risk — it could allow an attacker to overwrite config.php or re-seed the database. The portal will refuse to treat itself as production-ready while the installer is present.

9. File & Directory Permissions

Application files should be owned by the deploy user with the web server's group, so the server can read code and assets but cannot rewrite the application. Only storage/ needs to be writable by the web server.

PathModeOwner : GroupNotes
Directories (general)750deploy : www-data Readable/traversable by the web server group; not world-accessible.
Files (general)640deploy : www-data Readable by the server, writable only by the deploy user.
config/config.php640deploy : www-data Contains APP_KEY and DB creds. Never world-readable; never writable by the web server after install.
storage/770deploy : www-data (or owned by www-data) Must be writable by the web server.
storage/logs/770deploy : www-data Application log output; writable by the web server.
cd /var/www/blockid
sudo chown -R deploy:www-data .
sudo find . -type d -exec chmod 750 {} \;
sudo find . -type f -exec chmod 640 {} \;
sudo chmod -R 770 storage storage/logs
sudo chmod 640 config/config.php       # readable by group, never world-readable
Caution Never leave config/config.php writable by the web server after installation. A writable config file is a privilege-escalation vector and is also how an attacker could rewrite your APP_KEY or database credentials.

10. Environment Hardening

11. Cron Jobs (Optional but Recommended)

The application functions correctly without any cron jobs, but a few periodic maintenance tasks keep tables tidy and reporting accurate. Schedule them as the deploy user.

# /etc/cron.d/blockid  (run as the deploy user)

# Every 15 min: purge stale rate-limit and password-reset rows
*/15 * * * * deploy mysql blockid -e "DELETE FROM rate_limits WHERE expires_at < NOW();"
*/20 * * * * deploy mysql blockid -e "DELETE FROM password_resets WHERE expires_at < NOW();"

# Daily 02:30: mark expired credentials (cosmetic / reporting only)
30 2 * * *  deploy mysql blockid -e "UPDATE credentials SET status='expired' \
  WHERE expires_at < NOW() AND status='active';"

Alternatively, wrap the same logic in a maintenance PHP script and invoke it from cron:

30 2 * * * deploy php /var/www/blockid/storage/../bin/maintenance.php --sweep-credentials
Note The daily credential sweep is cosmetic. The portal already treats any credential whose expires_at is in the past as expired at verification time, so verification results are correct even without the cron job. The sweep simply makes the stored status column and admin reports reflect that state proactively.

12. Backup Strategy

Back up two things on independent schedules: the database and the configuration file.

Database

# Nightly logical backup (run as a user with read access)
mysqldump --single-transaction --quick --routines \
  blockid | gzip > /backups/blockid-$(date +%F).sql.gz

Use --single-transaction for a consistent snapshot without locking, retain backups off-host, and test restores regularly — an untested backup is not a backup.

Configuration

Back up config/config.php separately and securely. It contains the APP_KEY and the database credentials, so treat it as a secret: store it encrypted, with tight access control, apart from the database dumps.

Critical — APP_KEY APP_KEY is the AES key used to encrypt sensitive material (including the demo private keys stored by the portal). If you lose APP_KEY, that encrypted data is unrecoverable — a database backup alone will not save you. Always back up the config file alongside, but separately from, your database dumps.

13. Upgrade Strategy

  1. Back up first — take a fresh mysqldump and copy the current config/config.php (section 12).
  2. Enter maintenance — put the app behind a maintenance page / take it out of the load balancer so no writes happen mid-upgrade.
  3. Apply the new files, preserving your existing config/config.php (do not overwrite it). Re-apply file permissions from section 9.
  4. Run any new migrations — if the release ships schema changes, temporarily restore DDL grants (section 4) and apply them, then reduce privileges again.
  5. Reload PHP-FPM / Apache so opcache picks up the new code (required when opcache.validate_timestamps = 0).
  6. Verify ledger integrity — sign in and run "Verify Ledger Integrity" on the Ledger page to confirm the hash chain is intact after the upgrade.
  7. Exit maintenance and run the post-install verification (section 14).

14. Post-Install Verification

After installing or upgrading, confirm the deployment end to end:

  1. Sign in as the super administrator created during install.
  2. Create a test organization, a user, and a credential schema.
  3. Issue a credential against that schema to the test user.
  4. Verify the credential through the UI and confirm it shows as valid.
  5. Revoke the credential and confirm its status updates accordingly.
  6. Open the Ledger page and run "Verify Ledger Integrity" — it should report the chain as intact.
  7. Exercise the API endpoints directly, e.g. https://portal.example.com/api/credential-status.php and https://portal.example.com/api/verify-credential.php, and confirm sane JSON responses.
  8. Confirm the sensitive paths are blocked — each should return HTTP 403:
    curl -I https://portal.example.com/config/config.php
    curl -I https://portal.example.com/core/
    curl -I https://portal.example.com/storage/
  9. Confirm /install is gone — https://portal.example.com/install/index.php should return 404.

15. Checklists

Production checklist

Security checklist

16. Disclaimer

This is a production-ready STARTER application. Before selling it or processing real identity data, it must undergo: independent security review, penetration testing, privacy/legal review, cryptography review, backup/restore testing, compliance review, and load testing. The hash-chained ledger is tamper-evident, not a distributed blockchain, and does not by itself satisfy any regulatory or evidentiary standard.

Marketing site & subscriptions

The public marketing site lives under /marketing/ and is served by the same web root and the same core runtime as the product. It requires the core install plus the marketing migration.

1. Run the marketing migration

mysql -u <user> -p <database> < install/marketing_schema.sql

This creates the subscription_plans, subscriptions, marketing_leads, demo_requests, site_settings, marketing_pages, faq_items, testimonials, and feature_flags tables and seeds default plans, copy, FAQ, and SEO metadata.

2. Point your home page at the marketing site (optional)

Apache (in the project .htaccess) — redirect the bare domain to the marketing home:

RewriteEngine On
RewriteRule ^$ /marketing/index.php [L]

Nginx — add inside the server block:

location = / { return 302 /marketing/index.php; }

The product dashboard remains at /index.php and sign-in at /modules/auth/login.php.

3. Configure production URLs

Set APP_URL in config/config.php (HTTPS, no trailing slash). All marketing links, canonical/OG URLs, and form actions derive from it — no hard-coded domains.

4. Harden the public forms

Contact, demo, and signup forms already enforce CSRF, a honeypot field, per-IP rate limiting, server-side validation, output escaping, and audit logging. Tune thresholds in the form files and keep APP_ENV=production. No email is sent unless you wire an SMTP transport (see TODO(email) in marketing/contact.php).

5. Payments

No payment processor is bundled. Self-service signups create a subscriptions row with status trial. See SUBSCRIPTION_FLOW.md for the documented Stripe / manual-invoicing integration points (TODO(payments)).

6. Post-deploy checks

Reminder: the legal pages and testimonials are placeholders. Have counsel review legal content and replace placeholder testimonials with approved quotes before launch.


Root routing (marketing site = main website)

The document root is the project root (the folder containing index.php, /modules, /assets, /api, /docs). The root / serves the marketing homepage via DirectoryIndex index.php.

Protecting private areas: /config, /core, and /storage are denied over HTTP by the shipped .htaccess / Nginx rules. The dashboard and module entry points are protected at the application layer (session auth + RBAC), so no extra web-server rule is required for them. Delete /install after setup.

Apache

The shipped .htaccess sets DirectoryIndex index.php, forwards the Authorization header to PHP (required for API bearer tokens), 301-redirects legacy /marketing/* URLs to the root, and denies sensitive paths. Requires AllowOverride All and mod_rewrite + mod_headers.

Nginx

Use nginx.conf.sample: index index.php;, try_files $uri $uri/ /index.php?$query_string;, a 301 for /marketing/*, deny blocks for config|core|storage and source files, and fastcgi_param HTTP_AUTHORIZATION $http_authorization; so the API receives bearer tokens.

Database setup & migrations

Run the three SQL files (the installer does this automatically when the corresponding boxes are checked):

mysql -u USER -p DB < install/schema.sql
mysql -u USER -p DB < install/marketing_schema.sql
mysql -u USER -p DB < install/api_schema.sql

Grant the app DB user only the privileges it needs (DDL is needed during install/migration; you may reduce to DML afterward).

API endpoint setup

Base path /api/v1. Clients send Authorization: Bearer <token>. Ensure the web server forwards the Authorization header (Apache rewrite rule and Nginx fastcgi_param shown above). Requests are scope-checked, organization-isolated, rate-limited (120/min), and logged to api_logs. Create and manage tokens in the dashboard under API Management.

Troubleshooting: “Database.php line 41”

Line 41 of core/Database.php is the query-execution step. The raw symptom was an uncaught PDOException surfacing the file and line. This release converts connection/query failures into typed exceptions that render friendly pages (/errors/database.html, /errors/config-missing.html) and never expose a stack trace in production. The exact MySQL error is written to storage/logs/php-error.log.

If you hit it, check these causes in order:

  1. Missing/incomplete configconfig/config.php absent or DB_* constants empty. Re-run /install/index.php.
  2. Wrong credentials — host, name, user, or password incorrect for your host.
  3. Schema/migration not imported — a referenced table is missing. Import the three SQL files above.
  4. Shared-hosting buffered-query error — “Cannot execute queries while other unbuffered queries are active” (HY000/2014). The PDO layer now enables buffered queries to prevent this.
  5. Dashboard loaded before install finished.
  6. Wrong include path after moving files — every entry file must include core/bootstrap.php with the correct relative depth; BLOCKID_ROOT then resolves everything else.

Full guidance: /docs/troubleshooting.html.

Graceful error pages

/errors/config-missing.html (shown before installation / when config is missing) and /errors/database.html (shown on a database outage). Both are static, self-contained, and leak no secrets.

For role guides, API examples, and a fuller operations walkthrough, see the documentation site at /docs/.