feat: add Docker support with deployment scripts and database backup functionality
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.git
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
content.db
|
||||||
|
public/media
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
coverage
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS base
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
PNPM_HOME=/pnpm \
|
||||||
|
PATH=/pnpm:$PATH
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
|
||||||
|
ARG NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||||
|
ARG __NEXT_PRIVATE_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NEXT_PUBLIC_SERVER_URL=$NEXT_PUBLIC_SERVER_URL \
|
||||||
|
__NEXT_PRIVATE_ORIGIN=$__NEXT_PRIVATE_ORIGIN \
|
||||||
|
DATABASE_URL=file:/app/shared/content.db \
|
||||||
|
PAYLOAD_SECRET=build-time-placeholder \
|
||||||
|
CRON_SECRET=build-time-placeholder \
|
||||||
|
PREVIEW_SECRET=build-time-placeholder
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
RUN pnpm prune --prod
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
HOSTNAME=0.0.0.0 \
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
COPY --from=build /app /app
|
||||||
|
|
||||||
|
RUN mkdir -p /app/shared /app/public/media
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
60
deploy/vps/docker-compose.yml
Normal file
60
deploy/vps/docker-compose.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: bmp-website
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ${APP_ROOT:-/var/www/bmp-website}/current
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_SERVER_URL: https://${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}
|
||||||
|
__NEXT_PRIVATE_ORIGIN: https://${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}
|
||||||
|
image: bmp-website:latest
|
||||||
|
container_name: bmp_website
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ${APP_ROOT:-/var/www/bmp-website}/shared/.env
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
PORT: 3000
|
||||||
|
DATABASE_URL: file:/app/shared/content.db
|
||||||
|
NEXT_PUBLIC_SERVER_URL: https://${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}
|
||||||
|
volumes:
|
||||||
|
- ${APP_ROOT:-/var/www/bmp-website}/shared/content.db:/app/shared/content.db
|
||||||
|
- ${APP_ROOT:-/var/www/bmp-website}/shared/media:/app/public/media
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:latest
|
||||||
|
container_name: bmp_caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- caddy
|
||||||
|
- reverse-proxy
|
||||||
|
- --from
|
||||||
|
- ${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}
|
||||||
|
- --to
|
||||||
|
- app:3000
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
external: true
|
||||||
|
name: ${CADDY_DATA_VOLUME:-n8n-docker_caddy_data}
|
||||||
|
caddy_config:
|
||||||
|
external: true
|
||||||
|
name: ${CADDY_CONFIG_VOLUME:-n8n-docker_caddy_config}
|
||||||
@@ -1,218 +1,165 @@
|
|||||||
# VPS deployment
|
# VPS deployment
|
||||||
|
|
||||||
This project is a Node/Next/Payload application that currently uses:
|
The live VPS deployment runs this Payload/Next app with Docker Compose and Caddy.
|
||||||
|
|
||||||
- SQLite via `DATABASE_URL`
|
Current default target:
|
||||||
- local filesystem media in `public/media`
|
|
||||||
- no Docker
|
|
||||||
|
|
||||||
Treat code and persistent state separately. Git or rsync can move the code, but
|
```text
|
||||||
the database and media directory must be copied and backed up explicitly.
|
https://srv1.bayerischer-mittelstandspreis.de
|
||||||
|
|
||||||
## Persistent paths
|
|
||||||
|
|
||||||
Use one stable directory on the VPS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APP_ROOT=/var/www/bmp-website
|
|
||||||
APP_USER=deploy
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Recommended layout:
|
The app is intentionally split into committed code and uncommitted runtime state.
|
||||||
|
|
||||||
|
## Server Layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/var/www/bmp-website/
|
/var/www/bmp-website/
|
||||||
current/ # synced application code
|
current/ # synced application code
|
||||||
shared/
|
shared/
|
||||||
.env # production secrets
|
.env # production secrets, never committed
|
||||||
content.db # SQLite database
|
content.db # live SQLite database, never committed
|
||||||
media/ # Payload uploads
|
media/ # live Payload uploads, never committed
|
||||||
backups/
|
backups/ # server-side DB backups
|
||||||
```
|
```
|
||||||
|
|
||||||
Create it on the VPS:
|
The Docker Compose file lives in the repo at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
deploy/vps/docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
It starts:
|
||||||
|
|
||||||
|
- `bmp_website`: the Next/Payload app
|
||||||
|
- `bmp_caddy`: Caddy reverse proxy on ports `80` and `443`
|
||||||
|
|
||||||
|
Caddy reuses the certificate volumes from the former n8n deployment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
n8n-docker_caddy_data
|
||||||
|
n8n-docker_caddy_config
|
||||||
|
```
|
||||||
|
|
||||||
|
The old n8n containers and volumes may still exist on the server, but are
|
||||||
|
stopped after the BMP deployment takes over public traffic.
|
||||||
|
|
||||||
|
## What Belongs In Git
|
||||||
|
|
||||||
|
Commit:
|
||||||
|
|
||||||
|
- application code
|
||||||
|
- Payload schema/config changes
|
||||||
|
- `Dockerfile`
|
||||||
|
- `.dockerignore`
|
||||||
|
- `deploy/vps/docker-compose.yml`
|
||||||
|
- deployment scripts
|
||||||
|
|
||||||
|
Do not commit:
|
||||||
|
|
||||||
|
- `.env`
|
||||||
|
- `content.db`
|
||||||
|
- `public/media/`
|
||||||
|
- production backups
|
||||||
|
- any secrets
|
||||||
|
|
||||||
|
## Deploying Code
|
||||||
|
|
||||||
|
Normal deploys should be code-only. The live production database and uploads on
|
||||||
|
the VPS are authoritative and must not be overwritten by routine deploys.
|
||||||
|
|
||||||
|
From a clean, committed worktree:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p "$APP_ROOT"/{current,shared/media,backups}
|
pnpm deploy:vps
|
||||||
sudo chown -R "$APP_USER":"$APP_USER" "$APP_ROOT"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production environment
|
The deploy script:
|
||||||
|
|
||||||
Create `/var/www/bmp-website/shared/.env` on the VPS:
|
1. refuses to deploy a dirty worktree by default
|
||||||
|
2. rsyncs code to `/var/www/bmp-website/current`
|
||||||
|
3. excludes `.env`, `content.db`, `public/media`, `.next`, and `node_modules`
|
||||||
|
4. writes the deployed Git revision to `.deploy-revision`
|
||||||
|
5. builds the Docker image on the VPS
|
||||||
|
6. recreates only the `bmp_website` app container
|
||||||
|
7. verifies `/` and `/admin`
|
||||||
|
|
||||||
```dotenv
|
If you intentionally need an ad-hoc deploy before committing:
|
||||||
DATABASE_URL=file:/var/www/bmp-website/shared/content.db
|
|
||||||
PAYLOAD_SECRET=replace-with-existing-secret
|
|
||||||
NEXT_PUBLIC_SERVER_URL=https://example.com
|
|
||||||
CRON_SECRET=replace-with-existing-secret
|
|
||||||
PREVIEW_SECRET=replace-with-existing-secret
|
|
||||||
PORT=3000
|
|
||||||
NODE_ENV=production
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep `PAYLOAD_SECRET`, `CRON_SECRET`, and `PREVIEW_SECRET` stable once the site
|
|
||||||
is live. Do not commit the production `.env`.
|
|
||||||
|
|
||||||
`NEXT_PUBLIC_SERVER_URL` is read during the Next build, so set it correctly
|
|
||||||
before running `npm run build`.
|
|
||||||
|
|
||||||
If you already have the correct local `.env`, transfer it separately from the
|
|
||||||
application code and lock down its permissions:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scp .env "$SSH_TARGET:$APP_ROOT/shared/.env"
|
ALLOW_DIRTY=1 pnpm deploy:vps
|
||||||
ssh "$SSH_TARGET" "chmod 600 $APP_ROOT/shared/.env"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you need to package secrets in an archive, make that archive secret-only and
|
Use that sparingly. The preferred workflow is:
|
||||||
encrypt it before transfer:
|
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
tar -czf bmp-website-secrets.tar.gz .env
|
commit locally -> deploy that commit -> verify production
|
||||||
gpg -c bmp-website-secrets.tar.gz
|
|
||||||
scp bmp-website-secrets.tar.gz.gpg "$SSH_TARGET:$APP_ROOT/shared/"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then decrypt it on the VPS, move `.env` into place, and remove the temporary
|
## Configuration
|
||||||
archive files.
|
|
||||||
|
|
||||||
## Initial transfer
|
The scripts default to the current VPS:
|
||||||
|
|
||||||
Run these locally from the repository root. Replace the SSH target and domain
|
|
||||||
values first.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SSH_TARGET=deploy@example.com
|
SSH_USER=syntaxbullet
|
||||||
|
VPS_HOST=srv1.bayerischer-mittelstandspreis.de
|
||||||
|
VPS_PORT=22
|
||||||
|
SSH_KEY=~/.ssh/bmp-vps-deploy
|
||||||
APP_ROOT=/var/www/bmp-website
|
APP_ROOT=/var/www/bmp-website
|
||||||
|
APP_DOMAIN=srv1.bayerischer-mittelstandspreis.de
|
||||||
```
|
```
|
||||||
|
|
||||||
Stop any local dev server before copying `content.db`.
|
Override any value inline when needed:
|
||||||
|
|
||||||
Copy persistent state once:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rsync -az content.db "$SSH_TARGET:$APP_ROOT/shared/content.db"
|
APP_DOMAIN=www.example.com pnpm deploy:vps
|
||||||
rsync -az public/media/ "$SSH_TARGET:$APP_ROOT/shared/media/"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy application code without local state or build output:
|
If the Caddy container also needs to be recreated:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rsync -az --delete \
|
DEPLOY_CADDY=1 pnpm deploy:vps
|
||||||
--exclude '.env' \
|
|
||||||
--exclude '.next/' \
|
|
||||||
--exclude 'node_modules/' \
|
|
||||||
--exclude 'content.db' \
|
|
||||||
--exclude 'public/media/' \
|
|
||||||
./ "$SSH_TARGET:$APP_ROOT/current/"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Link the shared media directory into the app:
|
## Status And Logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh "$SSH_TARGET" "mkdir -p $APP_ROOT/current/public && rm -rf $APP_ROOT/current/public/media && ln -s $APP_ROOT/shared/media $APP_ROOT/current/public/media"
|
pnpm vps:status
|
||||||
ssh "$SSH_TARGET" "ln -sfn $APP_ROOT/shared/.env $APP_ROOT/current/.env"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not use `--delete` when syncing media unless the source is intentionally the
|
This prints:
|
||||||
source of truth and you have a current backup.
|
|
||||||
|
|
||||||
## Build on the VPS
|
- deployed revision
|
||||||
|
- running containers
|
||||||
|
- old n8n container status
|
||||||
|
- shared DB/media sizes
|
||||||
|
- disk usage
|
||||||
|
- recent app and Caddy logs
|
||||||
|
|
||||||
Install Node.js 22 or another version accepted by `package.json`, then run:
|
## Backing Up SQLite
|
||||||
|
|
||||||
|
Create a server-side SQLite backup before schema-sensitive deploys:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/bmp-website/current
|
pnpm vps:backup-db
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## systemd service
|
This briefly stops the app container, copies
|
||||||
|
`/var/www/bmp-website/shared/content.db` into `/var/www/bmp-website/backups/`,
|
||||||
|
then starts the app container again. Caddy remains running.
|
||||||
|
|
||||||
Create `/etc/systemd/system/bmp-website.service`:
|
## Rollback To Old n8n
|
||||||
|
|
||||||
```ini
|
The old n8n compose project was intentionally not deleted. If you need to replace
|
||||||
[Unit]
|
the BMP site with the previous n8n service:
|
||||||
Description=BMP website
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=deploy
|
|
||||||
Group=deploy
|
|
||||||
WorkingDirectory=/var/www/bmp-website/current
|
|
||||||
EnvironmentFile=/var/www/bmp-website/shared/.env
|
|
||||||
ExecStart=/usr/bin/env npm run start
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable and start it:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl daemon-reload
|
CONFIRM=replace-bmp-with-n8n pnpm vps:rollback:n8n
|
||||||
sudo systemctl enable --now bmp-website
|
|
||||||
sudo systemctl status bmp-website --no-pager
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## nginx reverse proxy
|
This stops the BMP Caddy/app containers and starts `/root/n8n-docker`.
|
||||||
|
|
||||||
Example server block:
|
## Production State Caution
|
||||||
|
|
||||||
```nginx
|
After launch, CMS edits and uploads happen on the VPS. Do not sync local
|
||||||
server {
|
`content.db` or `public/media/` over production unless you are intentionally
|
||||||
listen 80;
|
restoring or replacing production content and have a current backup.
|
||||||
server_name example.com www.example.com;
|
|
||||||
|
|
||||||
client_max_body_size 25m;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
After enabling the site, issue TLS certificates with your normal ACME client
|
|
||||||
such as Certbot.
|
|
||||||
|
|
||||||
## Repeat deploys
|
|
||||||
|
|
||||||
Before every deploy, back up the live SQLite database on the VPS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APP_ROOT=/var/www/bmp-website
|
|
||||||
sqlite3 "$APP_ROOT/shared/content.db" ".backup '$APP_ROOT/backups/content-$(date +%Y%m%d-%H%M%S).db'"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then sync code, rebuild, and restart:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -az --delete \
|
|
||||||
--exclude '.env' \
|
|
||||||
--exclude '.next/' \
|
|
||||||
--exclude 'node_modules/' \
|
|
||||||
--exclude 'content.db' \
|
|
||||||
--exclude 'public/media/' \
|
|
||||||
./ "$SSH_TARGET:$APP_ROOT/current/"
|
|
||||||
|
|
||||||
ssh "$SSH_TARGET" "cd $APP_ROOT/current && npm ci && npm run build && sudo systemctl restart bmp-website"
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh "$SSH_TARGET" "systemctl status bmp-website --no-pager"
|
|
||||||
curl -I https://example.com
|
|
||||||
curl -I https://example.com/admin
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"postbuild": "next-sitemap --config next-sitemap.config.cjs",
|
"postbuild": "next-sitemap --config next-sitemap.config.cjs",
|
||||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||||
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && pnpm build && pnpm start",
|
"dev:prod": "cross-env NODE_OPTIONS=--no-deprecation rm -rf .next && pnpm build && pnpm start",
|
||||||
|
"deploy:vps": "bash scripts/deploy-vps.sh",
|
||||||
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
|
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
|
||||||
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
||||||
"ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install",
|
"ii": "cross-env NODE_OPTIONS=--no-deprecation pnpm --ignore-workspace install",
|
||||||
@@ -39,7 +40,10 @@
|
|||||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||||
"test": "pnpm run test:int && pnpm run test:e2e",
|
"test": "pnpm run test:int && pnpm run test:e2e",
|
||||||
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts",
|
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts",
|
||||||
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
|
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts",
|
||||||
|
"vps:backup-db": "bash scripts/vps-backup-db.sh",
|
||||||
|
"vps:rollback:n8n": "bash scripts/vps-rollback-n8n.sh",
|
||||||
|
"vps:status": "bash scripts/vps-status.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@payloadcms/admin-bar": "3.85.1",
|
"@payloadcms/admin-bar": "3.85.1",
|
||||||
|
|||||||
69
scripts/deploy-vps.sh
Executable file
69
scripts/deploy-vps.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
SSH_USER="${SSH_USER:-syntaxbullet}"
|
||||||
|
VPS_HOST="${VPS_HOST:-srv1.bayerischer-mittelstandspreis.de}"
|
||||||
|
VPS_PORT="${VPS_PORT:-22}"
|
||||||
|
SSH_KEY="${SSH_KEY:-$HOME/.ssh/bmp-vps-deploy}"
|
||||||
|
APP_ROOT="${APP_ROOT:-/var/www/bmp-website}"
|
||||||
|
APP_DOMAIN="${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}"
|
||||||
|
PUBLIC_URL="${PUBLIC_URL:-https://$APP_DOMAIN}"
|
||||||
|
ALLOW_DIRTY="${ALLOW_DIRTY:-0}"
|
||||||
|
DEPLOY_CADDY="${DEPLOY_CADDY:-0}"
|
||||||
|
SKIP_VERIFY="${SKIP_VERIFY:-0}"
|
||||||
|
|
||||||
|
SSH_TARGET="$SSH_USER@$VPS_HOST"
|
||||||
|
COMPOSE_FILE="$APP_ROOT/current/deploy/vps/docker-compose.yml"
|
||||||
|
|
||||||
|
if [[ ! -f "$SSH_KEY" ]]; then
|
||||||
|
echo "Missing SSH key: $SSH_KEY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ALLOW_DIRTY" != "1" && -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "Refusing to deploy a dirty worktree." >&2
|
||||||
|
echo "Commit or stash changes first, or run with ALLOW_DIRTY=1 for an intentional ad-hoc deploy." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REVISION="$(git rev-parse --short=12 HEAD)"
|
||||||
|
|
||||||
|
echo "Deploying $REVISION to $SSH_TARGET:$APP_ROOT"
|
||||||
|
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude '.git/' \
|
||||||
|
--exclude '.env' \
|
||||||
|
--include '.env.example' \
|
||||||
|
--exclude '.env.*' \
|
||||||
|
--exclude '.next/' \
|
||||||
|
--exclude 'node_modules/' \
|
||||||
|
--exclude 'content.db' \
|
||||||
|
--exclude 'public/media/' \
|
||||||
|
--exclude '.DS_Store' \
|
||||||
|
--exclude 'tsconfig.tsbuildinfo' \
|
||||||
|
-e "ssh -i $SSH_KEY -p $VPS_PORT" \
|
||||||
|
./ "$SSH_TARGET:$APP_ROOT/current/"
|
||||||
|
|
||||||
|
ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "printf '%s\n' '$REVISION' > '$APP_ROOT/current/.deploy-revision'"
|
||||||
|
|
||||||
|
ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
|
||||||
|
set -e
|
||||||
|
cd '$APP_ROOT'
|
||||||
|
APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' build app
|
||||||
|
APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' up -d app
|
||||||
|
if [ '$DEPLOY_CADDY' = '1' ]; then
|
||||||
|
APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' up -d caddy
|
||||||
|
fi
|
||||||
|
sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
|
||||||
|
"
|
||||||
|
|
||||||
|
if [[ "$SKIP_VERIFY" != "1" ]]; then
|
||||||
|
echo "Verifying $PUBLIC_URL"
|
||||||
|
curl -fsSIL --max-time 20 "$PUBLIC_URL/" | sed -n '1,12p'
|
||||||
|
curl -fsSIL --max-time 20 "$PUBLIC_URL/admin" | sed -n '1,12p'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploy complete: $REVISION"
|
||||||
32
scripts/vps-backup-db.sh
Executable file
32
scripts/vps-backup-db.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SSH_USER="${SSH_USER:-syntaxbullet}"
|
||||||
|
VPS_HOST="${VPS_HOST:-srv1.bayerischer-mittelstandspreis.de}"
|
||||||
|
VPS_PORT="${VPS_PORT:-22}"
|
||||||
|
SSH_KEY="${SSH_KEY:-$HOME/.ssh/bmp-vps-deploy}"
|
||||||
|
APP_ROOT="${APP_ROOT:-/var/www/bmp-website}"
|
||||||
|
APP_DOMAIN="${APP_DOMAIN:-srv1.bayerischer-mittelstandspreis.de}"
|
||||||
|
|
||||||
|
SSH_TARGET="$SSH_USER@$VPS_HOST"
|
||||||
|
COMPOSE_FILE="$APP_ROOT/current/deploy/vps/docker-compose.yml"
|
||||||
|
|
||||||
|
ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
|
||||||
|
set -e
|
||||||
|
backup_dir='$APP_ROOT/backups'
|
||||||
|
backup_file=\"\$backup_dir/content-\$(date +%Y%m%d-%H%M%S).db\"
|
||||||
|
|
||||||
|
sudo install -d -o '$SSH_USER' -g '$SSH_USER' \"\$backup_dir\"
|
||||||
|
|
||||||
|
restart_app() {
|
||||||
|
APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' up -d app >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
trap restart_app EXIT
|
||||||
|
APP_ROOT='$APP_ROOT' APP_DOMAIN='$APP_DOMAIN' sudo docker compose -f '$COMPOSE_FILE' stop app >/dev/null
|
||||||
|
cp -p '$APP_ROOT/shared/content.db' \"\$backup_file\"
|
||||||
|
restart_app
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
ls -lh \"\$backup_file\"
|
||||||
|
"
|
||||||
26
scripts/vps-rollback-n8n.sh
Executable file
26
scripts/vps-rollback-n8n.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SSH_USER="${SSH_USER:-syntaxbullet}"
|
||||||
|
VPS_HOST="${VPS_HOST:-srv1.bayerischer-mittelstandspreis.de}"
|
||||||
|
VPS_PORT="${VPS_PORT:-22}"
|
||||||
|
SSH_KEY="${SSH_KEY:-$HOME/.ssh/bmp-vps-deploy}"
|
||||||
|
APP_ROOT="${APP_ROOT:-/var/www/bmp-website}"
|
||||||
|
CONFIRM="${CONFIRM:-}"
|
||||||
|
|
||||||
|
if [[ "$CONFIRM" != "replace-bmp-with-n8n" ]]; then
|
||||||
|
echo "This stops the BMP website and starts the old n8n compose project." >&2
|
||||||
|
echo "Run with CONFIRM=replace-bmp-with-n8n when you intentionally want that rollback." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SSH_TARGET="$SSH_USER@$VPS_HOST"
|
||||||
|
BMP_COMPOSE="$APP_ROOT/current/deploy/vps/docker-compose.yml"
|
||||||
|
N8N_COMPOSE="/root/n8n-docker/docker-compose.yml"
|
||||||
|
|
||||||
|
ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
|
||||||
|
set -e
|
||||||
|
sudo docker compose -f '$BMP_COMPOSE' stop caddy app
|
||||||
|
sudo docker compose -f '$N8N_COMPOSE' start
|
||||||
|
sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
|
||||||
|
"
|
||||||
31
scripts/vps-status.sh
Executable file
31
scripts/vps-status.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SSH_USER="${SSH_USER:-syntaxbullet}"
|
||||||
|
VPS_HOST="${VPS_HOST:-srv1.bayerischer-mittelstandspreis.de}"
|
||||||
|
VPS_PORT="${VPS_PORT:-22}"
|
||||||
|
SSH_KEY="${SSH_KEY:-$HOME/.ssh/bmp-vps-deploy}"
|
||||||
|
APP_ROOT="${APP_ROOT:-/var/www/bmp-website}"
|
||||||
|
LOG_LINES="${LOG_LINES:-80}"
|
||||||
|
|
||||||
|
SSH_TARGET="$SSH_USER@$VPS_HOST"
|
||||||
|
|
||||||
|
ssh -i "$SSH_KEY" -p "$VPS_PORT" "$SSH_TARGET" "
|
||||||
|
set -e
|
||||||
|
printf '== revision ==\n'
|
||||||
|
cat '$APP_ROOT/current/.deploy-revision' 2>/dev/null || true
|
||||||
|
printf '\n== containers ==\n'
|
||||||
|
sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
|
||||||
|
printf '\n== old n8n containers ==\n'
|
||||||
|
sudo docker ps -a --filter name=n8n_automation --filter name=caddy_proxy --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
|
||||||
|
printf '\n== shared state ==\n'
|
||||||
|
ls -lh '$APP_ROOT/shared/.env' '$APP_ROOT/shared/content.db'
|
||||||
|
find '$APP_ROOT/shared/media' -type f | wc -l
|
||||||
|
du -sh '$APP_ROOT/shared/media'
|
||||||
|
printf '\n== disk ==\n'
|
||||||
|
df -h / /var/lib/docker '$APP_ROOT' 2>/dev/null || df -h
|
||||||
|
printf '\n== app logs ==\n'
|
||||||
|
sudo docker logs --tail='$LOG_LINES' bmp_website
|
||||||
|
printf '\n== caddy logs ==\n'
|
||||||
|
sudo docker logs --tail='$LOG_LINES' bmp_caddy
|
||||||
|
"
|
||||||
@@ -6,13 +6,11 @@ import {
|
|||||||
lexicalEditor,
|
lexicalEditor,
|
||||||
} from '@payloadcms/richtext-lexical'
|
} from '@payloadcms/richtext-lexical'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
|
|
||||||
import { anyone } from '../access/anyone'
|
import { anyone } from '../access/anyone'
|
||||||
import { authenticated } from '../access/authenticated'
|
import { authenticated } from '../access/authenticated'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const mediaStaticDir = path.resolve(process.cwd(), 'public/media')
|
||||||
const dirname = path.dirname(filename)
|
|
||||||
|
|
||||||
export const Media: CollectionConfig = {
|
export const Media: CollectionConfig = {
|
||||||
slug: 'media',
|
slug: 'media',
|
||||||
@@ -48,7 +46,7 @@ export const Media: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
upload: {
|
upload: {
|
||||||
// Upload to the public/media directory in Next.js making them publicly accessible even outside of Payload
|
// Upload to the public/media directory in Next.js making them publicly accessible even outside of Payload
|
||||||
staticDir: path.resolve(dirname, '../../public/media'),
|
staticDir: mediaStaticDir,
|
||||||
adminThumbnail: 'thumbnail',
|
adminThumbnail: 'thumbnail',
|
||||||
focalPoint: true,
|
focalPoint: true,
|
||||||
imageSizes: [
|
imageSizes: [
|
||||||
|
|||||||
@@ -6,10 +6,28 @@ type MediaLike = {
|
|||||||
alt?: string | null
|
alt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payloadFilePrefix = '/api/media/file/'
|
||||||
|
|
||||||
|
export function normalizeMediaUrl(url: string | null | undefined) {
|
||||||
|
if (!url) return ''
|
||||||
|
if (url.startsWith(payloadFilePrefix)) return `/media/${url.slice(payloadFilePrefix.length)}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
if (parsed.pathname.startsWith(payloadFilePrefix)) {
|
||||||
|
return `/media/${parsed.pathname.slice(payloadFilePrefix.length)}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Relative URLs are handled above.
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
export function mediaUrl(media: unknown, fallback: string) {
|
export function mediaUrl(media: unknown, fallback: string) {
|
||||||
if (!media || typeof media !== 'object') return fallback
|
if (!media || typeof media !== 'object') return fallback
|
||||||
const doc = media as MediaLike
|
const doc = media as MediaLike
|
||||||
return doc.url || (doc.filename ? `/media/${doc.filename}` : fallback)
|
return normalizeMediaUrl(doc.url) || (doc.filename ? `/media/${doc.filename}` : fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mediaAlt(media: unknown, fallback: string) {
|
export function mediaAlt(media: unknown, fallback: string) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Footer, Header, Media } from '@/payload-types'
|
import type { Footer, Header, Media } from '@/payload-types'
|
||||||
|
import { normalizeMediaUrl } from '@/spa/cmsMediaField'
|
||||||
import { isLoginIntentHref } from '@/spa/loginIntent'
|
import { isLoginIntentHref } from '@/spa/loginIntent'
|
||||||
|
|
||||||
export type SpaIconName = 'instagram' | 'linkedin' | 'facebook'
|
export type SpaIconName = 'instagram' | 'linkedin' | 'facebook'
|
||||||
@@ -164,7 +165,7 @@ export const defaultFooterData: SpaFooterData = {
|
|||||||
|
|
||||||
const isMedia = (value: unknown): value is Media => Boolean(value && typeof value === 'object' && 'url' in value)
|
const isMedia = (value: unknown): value is Media => Boolean(value && typeof value === 'object' && 'url' in value)
|
||||||
const mediaToSpa = (value: unknown, fallback?: SpaMedia): SpaMedia | undefined => {
|
const mediaToSpa = (value: unknown, fallback?: SpaMedia): SpaMedia | undefined => {
|
||||||
if (isMedia(value)) return { url: value.url || fallback?.url, alt: value.alt || fallback?.alt }
|
if (isMedia(value)) return { url: normalizeMediaUrl(value.url) || fallback?.url, alt: value.alt || fallback?.alt }
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user