diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d577728
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,12 @@
+.git
+data/
+scripts/
+branding/
+README.md
+LICENSE
+nginx-security-headers.conf
+docker-compose.yml
+.dockerignore
+.gitignore
+app.js
+i18n.js
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..bfa5e46
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,96 @@
+name: Build & Push Docker Image
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+env:
+ IMAGE_NAME: xmrpay
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Extract version from tag
+ id: version
+ run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
+
+ - name: Inject version into source
+ run: |
+ sed -i "s|VERSION = '[^']*'|VERSION = '${{ steps.version.outputs.version }}'|" i18n.js
+ sed -i -E "s|(v)[^<]*()|\1${{ steps.version.outputs.version }}\2|" index.html
+
+ - name: Minify JS
+ run: |
+ npm i -g terser
+ terser app.js -c -m -o app.min.js
+ terser i18n.js -c -m -o i18n.min.js
+
+ - name: Update SRI hashes
+ run: |
+ sri() { echo "sha384-$(openssl dgst -sha384 -binary "$1" | openssl base64 -A)"; }
+
+ H_STYLE=$(sri style.css)
+ H_QRCODE=$(sri lib/qrcode.min.js)
+ H_I18N=$(sri i18n.min.js)
+ H_JSPDF=$(sri lib/jspdf.min.js)
+ H_CRYPTO=$(sri lib/xmr-crypto.bundle.js)
+
+ # Update dynamic SRI in app.js and re-minify
+ sed -i -E \
+ -e "s|(jspdf\.min\.js.*integrity\s*=\s*')sha384-[A-Za-z0-9+/=]+|\1${H_JSPDF}|" \
+ -e "s|(xmr-crypto\.bundle\.js.*integrity\s*=\s*')sha384-[A-Za-z0-9+/=]+|\1${H_CRYPTO}|" \
+ app.js
+ terser app.js -c -m -o app.min.js
+ H_APP=$(sri app.min.js)
+
+ # Update index.html
+ sed -i -E \
+ -e "s|(style\.css[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${H_STYLE}|" \
+ -e "s|(qrcode\.min\.js[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${H_QRCODE}|" \
+ -e "s|(i18n\.min\.js[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${H_I18N}|" \
+ -e "s|(app\.min\.js[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${H_APP}|" \
+ index.html
+
+ # Update privacy.html
+ sed -i -E \
+ -e "s|(style\.css[^\"]*\"\s+integrity=\")sha384-[A-Za-z0-9+/=]+|\1${H_STYLE}|" \
+ privacy.html
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ platforms: linux/amd64,linux/arm64
+ tags: |
+ schmidt1024/${{ env.IMAGE_NAME }}:latest
+ schmidt1024/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
+ ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest
+ ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/Caddyfile b/Caddyfile
new file mode 100644
index 0000000..cfc5a4d
--- /dev/null
+++ b/Caddyfile
@@ -0,0 +1,24 @@
+{$DOMAIN:localhost} {
+ root * /srv
+ encode gzip
+
+ # Security headers
+ header {
+ Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
+ X-Content-Type-Options "nosniff"
+ X-Frame-Options "DENY"
+ Referrer-Policy "no-referrer"
+ Permissions-Policy "geolocation=(), microphone=(), camera=()"
+ Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'"
+ }
+
+ # Short URL rewrite: /s/CODE -> s.php?c=CODE
+ @shorturl path_regexp short ^/s/([a-zA-Z0-9]+)$
+ rewrite @shorturl /s.php?c={re.short.1}
+
+ # PHP via FPM
+ php_fastcgi 127.0.0.1:9000
+
+ # Static files
+ file_server
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..60d571c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+FROM php:8.3-fpm-alpine AS base
+
+# Install PHP curl extension (needed for API proxies)
+RUN apk add --no-cache caddy curl-dev \
+ && docker-php-ext-install curl \
+ && rm -rf /var/cache/apk/*
+
+# PHP-FPM tuning for low-memory VPS
+RUN { \
+ echo '[www]'; \
+ echo 'pm = ondemand'; \
+ echo 'pm.max_children = 8'; \
+ echo 'pm.process_idle_timeout = 60s'; \
+ } > /usr/local/etc/php-fpm.d/zz-tuning.conf
+
+# App files
+COPY index.html privacy.html style.css sw.js favicon.svg s.php /srv/
+COPY app.min.js /srv/app.min.js
+COPY i18n.min.js /srv/i18n.min.js
+COPY api/ /srv/api/
+COPY lib/ /srv/lib/
+COPY fonts/ /srv/fonts/
+
+# Writable data directory
+RUN mkdir -p /srv/data && chown www-data:www-data /srv/data
+
+# Caddyfile
+COPY Caddyfile /etc/caddy/Caddyfile
+
+# Entrypoint: start PHP-FPM + Caddy
+COPY docker-entrypoint.sh /usr/local/bin/
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
+EXPOSE 80 443
+
+VOLUME ["/srv/data", "/data/caddy"]
+
+ENTRYPOINT ["docker-entrypoint.sh"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..7030887
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,27 @@
+services:
+ xmrpay:
+ image: ${XMRPAY_IMAGE:-schmidt1024/xmrpay:latest}
+ container_name: xmrpay
+ restart: unless-stopped
+ ports:
+ - "80:80"
+ - "443:443"
+ environment:
+ - DOMAIN=${DOMAIN:-localhost}
+ volumes:
+ - xmrpay-data:/srv/data
+ - caddy-data:/data/caddy
+
+ watchtower:
+ image: containrrr/watchtower
+ container_name: watchtower
+ restart: unless-stopped
+ environment:
+ - WATCHTOWER_CLEANUP=true
+ - WATCHTOWER_POLL_INTERVAL=21600
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+
+volumes:
+ xmrpay-data:
+ caddy-data:
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100644
index 0000000..3a0abf1
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e
+
+# Start PHP-FPM in background
+php-fpm &
+
+# Run Caddy in foreground
+exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..d7a2bb8
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,60 @@
+#!/bin/sh
+set -e
+
+# xmrpay.link — Self-hosting installer
+# Usage: curl -sL https://xmrpay.link/install.sh | sh -s your-domain.com
+
+DOMAIN="${1:-}"
+INSTALL_DIR="/opt/xmrpay"
+IMAGE="schmidt1024/xmrpay:latest"
+COMPOSE_URL="https://raw.githubusercontent.com/schmidt1024/xmrpay.link/master/docker-compose.yml"
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+info() { printf '\033[1;34m→\033[0m %s\n' "$1"; }
+ok() { printf '\033[1;32m✓\033[0m %s\n' "$1"; }
+fail() { printf '\033[1;31m✗\033[0m %s\n' "$1" >&2; exit 1; }
+
+# ── Preflight ─────────────────────────────────────────────────────────────────
+
+[ "$(id -u)" -eq 0 ] || fail "Run as root: curl -sL https://xmrpay.link/install.sh | sudo sh -s $DOMAIN"
+[ -n "$DOMAIN" ] || fail "Usage: curl -sL https://xmrpay.link/install.sh | sh -s YOUR-DOMAIN.COM"
+
+# ── Install Docker if missing ─────────────────────────────────────────────────
+
+if ! command -v docker >/dev/null 2>&1; then
+ info "Installing Docker..."
+ curl -fsSL https://get.docker.com | sh
+ systemctl enable --now docker
+ ok "Docker installed"
+else
+ ok "Docker found"
+fi
+
+# ── Set up xmrpay ────────────────────────────────────────────────────────────
+
+info "Setting up xmrpay in $INSTALL_DIR..."
+mkdir -p "$INSTALL_DIR"
+cd "$INSTALL_DIR"
+
+curl -fsSL "$COMPOSE_URL" -o docker-compose.yml
+
+cat > .env <