Add Docker Compose deployment documentation to README fixing #2

This commit is contained in:
Tamir Suliman
2026-05-16 15:40:58 +02:00
parent 94901fa847
commit b96e787aa6
15 changed files with 628 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Git
.git
.gitignore
# Docker files (not needed inside the image)
docker-compose.yml
docker-compose.override.yml
Makefile
.dockerignore
.env
.env.example
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Documentation
README.md
SECURITY.md
LICENSE
# Runtime directories (created at build time or mounted as volumes)
sessions/*
!sessions/.htaccess
+49
View File
@@ -0,0 +1,49 @@
# ─────────────────────────────────────────────────────────────────────────────
# TOTPVault — Environment Variables
# Copy this file to .env and fill in real values before running docker compose.
# ─────────────────────────────────────────────────────────────────────────────
# ── Application ──────────────────────────────────────────────────────────────
# Base URL of the application (no trailing slash).
# For local Docker deployment this is typically http://localhost:8080
APP_URL=http://localhost:8080
# ── Database ─────────────────────────────────────────────────────────────────
# DB_HOST must match the Compose service name for the database container.
DB_HOST=db
DB_PORT=3306
DB_NAME=totpvault
DB_USER=totpvault
DB_PASSWORD=change_me_db_password
DB_ROOT_PASSWORD=change_me_root_password
# ── Encryption ───────────────────────────────────────────────────────────────
# Must be exactly 32 bytes, base64-encoded. Generate with:
# php -r "echo base64_encode(random_bytes(32)) . PHP_EOL;"
# WARNING: Changing this after storing tokens will make them unreadable!
ENCRYPTION_KEY=
# ── Session ──────────────────────────────────────────────────────────────────
SESSION_COOKIE_NAME=totpvault_session
SESSION_LIFETIME=2592000
# ── Mail (MailerSend) ────────────────────────────────────────────────────────
# Sign up at https://www.mailersend.com and create an API token.
MAILERSEND_KEY=
MAIL_FROM_EMAIL=noreply@yourdomain.com
MAIL_FROM_NAME=TOTPVault
# ── OAuth: Google ────────────────────────────────────────────────────────────
# https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# ── OAuth: Microsoft ─────────────────────────────────────────────────────────
# https://portal.azure.com → App registrations
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
# ── OAuth: GitHub ────────────────────────────────────────────────────────────
# https://github.com/settings/developers
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
+19
View File
@@ -0,0 +1,19 @@
# Secrets — never commit real configuration
.env
# PHP sessions
sessions/*
!sessions/.htaccess
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Docker volumes (if stored locally)
docker/data/
+9
View File
@@ -0,0 +1,9 @@
# Root .htaccess — route all requests to front controller
RewriteEngine On
# If the requested file or directory exists, serve it directly
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Route everything else through index.php
RewriteRule ^(.*)$ index.php [QSA,L]
+72
View File
@@ -0,0 +1,72 @@
# ─────────────────────────────────────────────────────────────────────────────
# TOTPVault — Production Dockerfile
# Base: Official PHP 8.2 with Apache on Debian Bookworm
# ─────────────────────────────────────────────────────────────────────────────
FROM php:8.2-apache-bookworm
LABEL maintainer="TOTPVault" \
description="Self-hosted TOTP manager with AES-256-GCM encryption"
# ── 1. Enable Apache modules ────────────────────────────────────────────────
# mod_rewrite is required for .htaccess URL routing to index.php
RUN a2enmod rewrite headers
# ── 2. Install PHP extensions ────────────────────────────────────────────────
# pdo_mysql — database access via PDO
# mysqli — fallback MySQL driver (some libraries expect it)
# openssl — bundled with php:8.2, but we verify it's present
# curl — required by sendMail() for MailerSend API calls
RUN docker-php-ext-install pdo pdo_mysql mysqli \
&& php -m | grep -qi openssl \
&& php -m | grep -qi curl
# ── 3. Set Apache DocumentRoot to the project directory ─────────────────────
# The app expects index.php at the web root, with .htaccess rewriting.
ENV APACHE_DOCUMENT_ROOT=/var/www/html
RUN sed -ri "s|/var/www/html|${APACHE_DOCUMENT_ROOT}|g" \
/etc/apache2/sites-available/*.conf \
/etc/apache2/apache2.conf
# ── 4. Copy custom Apache vhost configuration ───────────────────────────────
# Ensures AllowOverride All so .htaccess files work, and denies direct
# browsing of protected directories as a defense-in-depth layer.
COPY docker/000-default.conf /etc/apache2/sites-available/000-default.conf
# ── 5. Copy application files ───────────────────────────────────────────────
COPY . /var/www/html/
# ── 6. Create sessions directory with proper permissions ─────────────────────
# www-data (Apache user) needs write access for PHP session files.
# The .htaccess inside sessions/ blocks direct HTTP access.
RUN mkdir -p /var/www/html/sessions \
&& chown www-data:www-data /var/www/html/sessions \
&& chmod 750 /var/www/html/sessions
# ── 7. Set secure permissions on config directory ────────────────────────────
# config/ contains secrets at runtime; restrict access to www-data only.
RUN chown -R www-data:www-data /var/www/html/config \
&& chmod 750 /var/www/html/config
# ── 8. Ensure all .htaccess files are present and readable ──────────────────
RUN chmod 644 /var/www/html/.htaccess \
/var/www/html/config/.htaccess \
/var/www/html/src/.htaccess \
/var/www/html/sessions/.htaccess \
/var/www/html/templates/.htaccess
# ── 9. Configure PHP for production ─────────────────────────────────────────
# Disable display_errors in the container (log them instead).
# Use the sessions/ directory for session files.
RUN { \
echo 'display_errors = Off'; \
echo 'log_errors = On'; \
echo 'error_log = /dev/stderr'; \
echo 'session.save_path = /var/www/html/sessions'; \
echo 'session.cookie_httponly = 1'; \
echo 'session.cookie_samesite = Lax'; \
echo 'expose_php = Off'; \
} > /usr/local/etc/php/conf.d/totpvault.ini
EXPOSE 80
# Apache runs in the foreground by default via the base image entrypoint.
+35
View File
@@ -0,0 +1,35 @@
# ─────────────────────────────────────────────────────────────────────────────
# TOTPVault — Makefile shortcuts for Docker Compose
# ─────────────────────────────────────────────────────────────────────────────
.PHONY: up down logs rebuild shell db-shell status
## Start all services in the background
up:
docker compose up -d --build
## Stop all services
down:
docker compose down
## Follow application logs
logs:
docker compose logs -f app
## Rebuild images and restart
rebuild:
docker compose down
docker compose build --no-cache
docker compose up -d
## Open a bash shell inside the app container
shell:
docker compose exec app bash
## Open a MySQL shell inside the database container
db-shell:
docker compose exec db mariadb -u "$${DB_USER:-totpvault}" -p"$${DB_PASSWORD}" "$${DB_NAME:-totpvault}"
## Show container status
status:
docker compose ps
+112
View File
@@ -411,6 +411,118 @@ CREATE TABLE `users` (
└── .htaccess # Rewrites all requests to index.php
```
## Docker Compose Deployment
A complete Docker setup is included for local or self-hosted deployment. It runs the PHP app under Apache and MariaDB in two containers behind a private bridge network.
### Quick start
```bash
# 1. Clone and enter the project
git clone https://github.com/token2/TOTPvault.git
cd TOTPVault
# 2. Create your environment file
cp .env.example .env
# 3. Generate a secure encryption key (required)
php -r "echo base64_encode(random_bytes(32)) . PHP_EOL;"
# Paste the output as ENCRYPTION_KEY in .env
# 4. Edit .env — at minimum fill in:
# - ENCRYPTION_KEY (from step 3)
# - DB_PASSWORD (choose any strong password)
# - DB_ROOT_PASSWORD
# - MAILERSEND_KEY (if you want magic-link emails)
# - OAuth client IDs/secrets for any providers you use
# 5. Start everything
docker compose up -d --build
# 6. Open the app
open http://localhost:8080
```
### Makefile shortcuts
| Command | Description |
|---|---|
| `make up` | Build and start all services |
| `make down` | Stop all services |
| `make logs` | Follow application logs |
| `make rebuild` | Full rebuild (no cache) and restart |
| `make shell` | Bash shell inside the app container |
| `make db-shell` | MariaDB shell inside the database container |
### Common commands
```bash
# View logs
docker compose logs -f app
# Stop everything
docker compose down
# Reset database (destroys all data!)
docker compose down -v
```
### Persistent data
| Volume | Purpose |
|---|---|
| `totpvault-db-data` | MariaDB database files — survives container rebuilds |
| `totpvault-sessions` | PHP session files — keeps users logged in across restarts |
To list volumes: `docker volume ls | grep totpvault`
### How configuration works
- `config/config.docker.php` is mounted as `config/config.php` inside the container (read-only).
- All settings are read from environment variables defined in `.env`.
- OAuth redirect URIs are derived automatically from `APP_URL`.
- The original `config/config.php` on the host is **not modified** and remains usable for non-Docker deployments.
- **Never commit `.env` or real secrets to Git.** The `.gitignore` excludes `.env` by default.
### Database initialisation
On first startup, MariaDB automatically runs `docker/init-db.sql` to create all tables. This happens only when the data volume is empty. To re-initialise:
```bash
docker compose down -v # removes the data volume
docker compose up -d --build
```
### Troubleshooting
**App cannot connect to database**
- Ensure `DB_HOST=db` in `.env` (must match the Compose service name).
- Check that the `db` container is healthy: `docker compose ps`
- Verify `DB_USER`, `DB_PASSWORD`, and `DB_NAME` match between the app and db service.
- The app waits for the db healthcheck before starting (`depends_on: condition: service_healthy`).
**Schema not imported**
- Init scripts only run on a **fresh** data volume. If you changed the schema, reset with `docker compose down -v` first.
- Check MariaDB logs: `docker compose logs db | head -100`
**.htaccess rewrite not working**
- Verify `mod_rewrite` is enabled: `docker compose exec app apache2ctl -M | grep rewrite`
- The Dockerfile enables it automatically. If you see 404 errors for routes like `/dashboard`, check that `AllowOverride All` is set in `docker/000-default.conf`.
**Missing encryption key**
- `ENCRYPTION_KEY` must be set in `.env`. Generate it with:
```
php -r "echo base64_encode(random_bytes(32)) . PHP_EOL;"
```
- If the key changes after tokens have been stored, existing encrypted secrets become **permanently unreadable**.
**MailerSend not configured**
- Magic-link login requires a valid `MAILERSEND_KEY`. Without it, email sending will silently fail.
- Sign up at [mailersend.com](https://www.mailersend.com), verify a domain, and create an API token.
- Set `MAIL_FROM_EMAIL` to an address on your verified domain.
---
## Demo
Check out the live demo here: [Live Demo](https://totp.token2.swiss/)
+3
View File
@@ -0,0 +1,3 @@
# Deny all direct HTTP access to config/
# Secrets and application configuration must never be served by Apache.
Require all denied
+84
View File
@@ -0,0 +1,84 @@
<?php
// config/config.docker.php
//
// Docker-specific configuration that reads ALL settings from environment
// variables. This file is mounted as config/config.php inside the container
// by docker-compose.yml. It is never used in a non-Docker deployment.
//
// Do NOT commit this file with real secrets. All values come from .env.
$appUrl = rtrim(getenv('APP_URL') ?: 'http://localhost:8080', '/');
return [
// ── Application ────────────────────────────────────────────────────────
'app_url' => $appUrl,
'app_name' => 'TOTPVault',
'app_env' => getenv('APP_ENV') ?: 'production',
// ── Database ───────────────────────────────────────────────────────────
// DB_HOST should be the Compose service name (default: "db").
'db' => [
'host' => getenv('DB_HOST') ?: 'db',
'port' => (int)(getenv('DB_PORT') ?: 3306),
'dbname' => getenv('DB_NAME') ?: 'totpvault',
'charset' => 'utf8mb4',
'user' => getenv('DB_USER') ?: 'totpvault',
'password' => getenv('DB_PASSWORD') ?: '',
],
// ── Encryption ─────────────────────────────────────────────────────────
// Must be exactly 32 bytes, base64-encoded. Generate with:
// php -r "echo base64_encode(random_bytes(32)) . PHP_EOL;"
'encryption_key' => getenv('ENCRYPTION_KEY') ?: '',
// ── Session ────────────────────────────────────────────────────────────
'session' => [
'cookie_name' => getenv('SESSION_COOKIE_NAME') ?: 'totpvault_session',
'lifetime' => (int)(getenv('SESSION_LIFETIME') ?: 2592000),
],
// ── Mail (MailerSend) ──────────────────────────────────────────────────
'mail' => [
'mailersend_key' => getenv('MAILERSEND_KEY') ?: '',
'from_email' => getenv('MAIL_FROM_EMAIL') ?: 'noreply@localhost',
'from_name' => getenv('MAIL_FROM_NAME') ?: 'TOTPVault',
],
// ── OAuth providers ────────────────────────────────────────────────────
// Redirect URIs are derived from APP_URL automatically.
// Leave client_id empty to effectively disable a provider.
'oauth' => [
'google' => [
'client_id' => getenv('GOOGLE_CLIENT_ID') ?: '',
'client_secret' => getenv('GOOGLE_CLIENT_SECRET') ?: '',
'redirect_uri' => $appUrl . '/auth/callback/google',
'auth_url' => 'https://accounts.google.com/o/oauth2/v2/auth',
'token_url' => 'https://oauth2.googleapis.com/token',
'userinfo_url' => 'https://www.googleapis.com/oauth2/v3/userinfo',
'scope' => 'openid email profile',
],
'microsoft' => [
'client_id' => getenv('MICROSOFT_CLIENT_ID') ?: '',
'client_secret' => getenv('MICROSOFT_CLIENT_SECRET') ?: '',
'redirect_uri' => $appUrl . '/auth/callback/microsoft',
'auth_url' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
'token_url' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
'userinfo_url' => 'https://graph.microsoft.com/v1.0/me',
'scope' => 'openid email profile User.Read',
],
'github' => [
'client_id' => getenv('GITHUB_CLIENT_ID') ?: '',
'client_secret' => getenv('GITHUB_CLIENT_SECRET') ?: '',
'redirect_uri' => $appUrl . '/auth/callback/github',
'auth_url' => 'https://github.com/login/oauth/authorize',
'token_url' => 'https://github.com/login/oauth/access_token',
'userinfo_url' => 'https://api.github.com/user',
'scope' => 'read:user user:email',
],
],
];
+95
View File
@@ -0,0 +1,95 @@
# ─────────────────────────────────────────────────────────────────────────────
# TOTPVault — Docker Compose
# Usage:
# cp .env.example .env # then fill in real values
# docker compose up -d --build
# open http://localhost:8080
# ─────────────────────────────────────────────────────────────────────────────
services:
# ── PHP + Apache application ───────────────────────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
container_name: totpvault-app
restart: unless-stopped
ports:
- "8080:80"
environment:
# All env vars are read by config/config.docker.php inside the container.
- APP_URL=${APP_URL:-http://localhost:8080}
- DB_HOST=${DB_HOST:-db}
- DB_PORT=${DB_PORT:-3306}
- DB_NAME=${DB_NAME:-totpvault}
- DB_USER=${DB_USER:-totpvault}
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
- ENCRYPTION_KEY=${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}
- SESSION_COOKIE_NAME=${SESSION_COOKIE_NAME:-totpvault_session}
- SESSION_LIFETIME=${SESSION_LIFETIME:-2592000}
- MAILERSEND_KEY=${MAILERSEND_KEY:-}
- MAIL_FROM_EMAIL=${MAIL_FROM_EMAIL:-noreply@localhost}
- MAIL_FROM_NAME=${MAIL_FROM_NAME:-TOTPVault}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- MICROSOFT_CLIENT_ID=${MICROSOFT_CLIENT_ID:-}
- MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET:-}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
volumes:
# Mount the Docker-specific config as config/config.php inside the container.
# This keeps the original config/config.php on the host untouched.
- ./config/config.docker.php:/var/www/html/config/config.php:ro
# Persist PHP sessions across container restarts.
- totpvault-sessions:/var/www/html/sessions
depends_on:
db:
condition: service_healthy
networks:
- totpvault-net
healthcheck:
test: ["CMD", "curl", "-f", "-s", "-o", "/dev/null", "http://localhost:80/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# ── MariaDB database ──────────────────────────────────────────────────────
db:
image: mariadb:10.11
container_name: totpvault-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD is required}
MYSQL_DATABASE: ${DB_NAME:-totpvault}
MYSQL_USER: ${DB_USER:-totpvault}
MYSQL_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
volumes:
# Persist database data across container restarts and rebuilds.
- totpvault-db-data:/var/lib/mysql
# Initialise the schema on first startup only.
# Files in /docker-entrypoint-initdb.d/ run once when the data volume is empty.
- ./docker/init-db.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
# The database port is NOT exposed to the host by default for security.
# Uncomment the following lines if you need direct access for debugging:
# ports:
# - "3306:3306"
networks:
- totpvault-net
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
volumes:
totpvault-db-data:
driver: local
totpvault-sessions:
driver: local
networks:
totpvault-net:
driver: bridge
+35
View File
@@ -0,0 +1,35 @@
<VirtualHost *:80>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html
# ── AllowOverride All — required for .htaccess rewrite rules ────────────
<Directory /var/www/html>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# ── Defense-in-depth: deny direct HTTP access to protected directories ──
# These directories also have their own .htaccess deny rules, but we
# enforce it at the vhost level as well in case AllowOverride is changed.
<Directory /var/www/html/config>
Require all denied
</Directory>
<Directory /var/www/html/src>
Require all denied
</Directory>
<Directory /var/www/html/sessions>
Require all denied
</Directory>
<Directory /var/www/html/templates>
Require all denied
</Directory>
# ── Logging ─────────────────────────────────────────────────────────────
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
+76
View File
@@ -0,0 +1,76 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- TOTPVault — Database initialisation for Docker
--
-- This script runs automatically on first MariaDB startup via
-- /docker-entrypoint-initdb.d/. It creates all tables with proper
-- PRIMARY KEYs and AUTO_INCREMENT that the application expects.
--
-- The MYSQL_DATABASE environment variable (set in docker-compose.yml)
-- tells the MariaDB image to CREATE the database and USE it before
-- running this script, so we do not need a CREATE DATABASE statement.
-- ─────────────────────────────────────────────────────────────────────────────
-- ── magic_links ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS `magic_links` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`token_hash` varchar(64) NOT NULL,
`used` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`expires_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
INDEX `idx_magic_links_email` (`email`),
INDEX `idx_magic_links_token_hash` (`token_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── otp_profiles ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS `otp_profiles` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
`issuer` varchar(255) DEFAULT '',
`secret_encrypted` text NOT NULL COMMENT 'AES-256-GCM encrypted base32 secret',
`algorithm` enum('SHA1','SHA256','SHA512') DEFAULT 'SHA1',
`digits` tinyint(4) DEFAULT 6 COMMENT '6, 8 or 10',
`period` smallint(6) DEFAULT 30 COMMENT 'seconds',
`color` varchar(7) DEFAULT '#6366f1',
`icon` varchar(50) DEFAULT 'shield',
`hide_code` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
INDEX `idx_otp_profiles_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── profile_shares ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS `profile_shares` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`profile_id` int(11) NOT NULL,
`shared_by_user_id` int(11) NOT NULL,
`shared_with_email` varchar(255) NOT NULL,
`shared_with_user_id` int(11) DEFAULT NULL,
`can_edit` tinyint(1) DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
INDEX `idx_profile_shares_profile_id` (`profile_id`),
INDEX `idx_profile_shares_shared_with_email` (`shared_with_email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── users ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`name` varchar(255) DEFAULT NULL,
`avatar_url` varchar(500) DEFAULT NULL,
`google_id` varchar(255) DEFAULT NULL,
`microsoft_id` varchar(255) DEFAULT NULL,
`github_id` varchar(255) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE INDEX `idx_users_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+3
View File
@@ -0,0 +1,3 @@
# Deny all direct HTTP access to sessions/
# PHP session files contain sensitive authentication data.
Require all denied
+3
View File
@@ -0,0 +1,3 @@
# Deny all direct HTTP access to src/
# PHP source files are included by the front controller, not served directly.
Require all denied
+3
View File
@@ -0,0 +1,3 @@
# Deny all direct HTTP access to templates/
# Templates are rendered by PHP, not served directly.
Require all denied