/). 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.
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.
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:
index.php — the application front controller (the document root points here)./modules — the server-rendered feature pages (organizations, users, schemas,
credentials, the ledger view, etc.)./api — stateless JSON endpoints such as /api/credential-status.php
and /api/verify-credential.php used for credential lookups and verification./assets — static CSS, JavaScript, and images./config, /core, /storage — sensitive internal
directories that must never be reachable over HTTP./install — the one-time web installer (deleted after setup).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.
| Component | Requirement |
|---|---|
| Operating system | Any modern Linux (Debian/Ubuntu, RHEL/Rocky/Alma) or a comparable Unix-like host. Windows Server with PHP is possible but not the target. |
| Web server | Apache 2.4+ (with mod_rewrite and mod_headers)
or Nginx with PHP-FPM. |
| PHP | 8.2 or newer. Required extensions:
pdo_mysql, openssl, mbstring, and
json (bundled in PHP 8). opcache is strongly recommended
in production. |
| Database | MySQL 8.0 or newer (a compatible MariaDB may work but
is not the reference target). The schema uses utf8mb4. |
| TLS | A valid certificate is required for production. The app sets secure cookies and assumes it is served over HTTPS. |
| Tooling | The 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'
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
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.
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.
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'.
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;
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>
.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.
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;
}
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.
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:
COOKIE_SECURE=true in config/config.php and
session.cookie_secure = 1 in PHP so session cookies are only sent over TLS.# Apache (in the :443 vhost), or Nginx via add_header
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
The application self-installs through a one-time web installer. Follow these steps in order:
/var/www/blockid) and set ownership
and permissions per section 9.https://portal.example.com/install/index.php.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./install directory (see the warning box below).php install/seed.php --confirm (only meaningful before you delete the installer
scripts, and only on non-production demos).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.
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.
| Path | Mode | Owner : Group | Notes |
|---|---|---|---|
| Directories (general) | 750 | deploy : www-data |
Readable/traversable by the web server group; not world-accessible. |
| Files (general) | 640 | deploy : www-data |
Readable by the server, writable only by the deploy user. |
config/config.php | 640 | deploy : www-data |
Contains APP_KEY and DB creds. Never world-readable; never writable by the
web server after install. |
storage/ | 770 | deploy : www-data (or owned by www-data) |
Must be writable by the web server. |
storage/logs/ | 770 | deploy : 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
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.
APP_ENV=production in config/config.php — hides
stack traces and verbose errors in the UI.expose_php Off in PHP and ServerSignature Off
/ server_tokens off on the web server — stop leaking software versions.Options -Indexes on Apache; Nginx does
not list by default)./config, /core, and /storage unreachable
over HTTP — rely on the shipped .htaccess / nginx.conf.sample
deny rules and verify they return 403 (see section 14).localhost when co-located).COOKIE_SECURE=true and confirm the session timeout,
BCRYPT_COST, login-lockout, and rate-limit settings in config.php are
appropriate for your threat model.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
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.
Back up two things on independent schedules: the database and the configuration file.
# 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.
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.
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.
mysqldump and copy the current
config/config.php (section 12).config/config.php
(do not overwrite it). Re-apply file permissions from section 9.opcache.validate_timestamps = 0).After installing or upgrading, confirm the deployment end to end:
https://portal.example.com/api/credential-status.php and
https://portal.example.com/api/verify-credential.php, and confirm sane JSON
responses.curl -I https://portal.example.com/config/config.php
curl -I https://portal.example.com/core/
curl -I https://portal.example.com/storage//install is gone —
https://portal.example.com/install/index.php should return
404.index.php), not public/.pdo_mysql, openssl, mbstring, json, and opcache.APP_ENV=production and display_errors=Off; PHP errors logged outside the web root.utf8mb4 with a least-privilege app user.COOKIE_SECURE=true.config/config.php is 640 and not web-writable./install directory deleted./config, /core, /storage return 403 over HTTP.*.sql, *.md, *.sample, and config.example.php are denied.expose_php Off and ServerSignature Off / server_tokens off.APP_KEY backed up securely and separately.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.
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.
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.
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.
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.
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).
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)).
/marketing/index.php — hero, pricing, and FAQ render.privacy/terms/cookies) after legal review.Reminder: the legal pages and testimonials are placeholders. Have counsel review legal content and replace placeholder testimonials with approved quotes before launch.
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.
/ → marketing homepage/product.php, /solutions.php, /pricing.php, /security.php, /compliance.php, /resources.php, /about.php, /contact.php, /request-demo.php, /signup.php → public pages/login.php → sign-in · /logout.php → sign-out (POST + CSRF)/dashboard.php → dashboard (requires login; unauthenticated users are redirected to /login.php)/modules/** → private dashboard pages (each calls Auth::require())/api/v1/** → JSON API · /docs/** → documentationProtecting 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.
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.
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.
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).
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.
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:
config/config.php absent or DB_* constants empty. Re-run /install/index.php.core/bootstrap.php with the correct relative depth; BLOCKID_ROOT then resolves everything else.Full guidance: /docs/troubleshooting.html.
/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/.