# Deployment (DigitalOcean Droplet)

Two flows:

| What | How | When |
|---|---|---|
| **Code + config** | `git tag v1.2.0 && git push origin v1.2.0` → GitHub Actions builds and deploys | when code or config changed |
| **Content** (a post with its media) | `scripts/deploy/push-content.sh /blog/my-post` from your machine | when a post is ready |

The repository holds code and exported configuration only. Core, contrib,
`vendor/`, `web/libraries/` and the theme CSS are built by the workflow;
uploaded files and content live on the server.

## How a tagged deploy works

`.github/workflows/deploy.yml`, on a pushed `v*` tag:

1. `composer install --no-dev --optimize-autoloader`, then `npm ci && npm run build:css` in
   `web/themes/custom/marcus_theme`.
2. Packs the build, uploads it, unpacks it into `releases/<tag>`.
3. Runs `scripts/deploy/deploy.sh <tag>` on the server:
   - **backup** to `backups/<time>-<tag>/`: `db.sql.gz` (drush sql:dump), `files.tar.gz`
     (without generated css/js/styles), `code.tar.gz` (the live release);
   - links `shared/files` and `shared/settings.production.php` into the release;
   - maintenance mode on, switches `current` to the new release, reloads PHP-FPM;
   - **`drush deploy`**: `updatedb`, `config:import`, `cache:rebuild`, deploy hooks;
   - maintenance mode off; rebuilds llms.txt and the sitemap with `SITE_URL` (drush gets it as
     `DRUSH_OPTIONS_URI`; config:import brings back the locally exported llms.txt with local URLs);
   - checks that `SITE_URL/` answers 200;
   - **if drush deploy or the check fails**: switches back to the previous release and
     restores the database from the backup just taken;
   - keeps the last 5 releases and 10 backups.

Before tagging: export configuration (`ddev drush config:export`) and commit it.
`drush deploy` imports exactly what is in `config/sync`.

## How a content push works

`scripts/deploy/push-content.sh /blog/my-post` (settings in `.deploy.env`, copy
`.deploy.env.example`):

1. `ddev drush marcus:content-export` writes the page, its media, files and terms
   (core's default content format; Canvas maps the media inside component trees to UUIDs)
   to `.content-push/` (gitignored). **User accounts are never exported.**
2. Backs up the production database (`backups/<time>-content/db.sql.gz`).
3. Rsyncs the export to the server and runs `drush marcus:content-import` there:
   new content is created, content that exists (same UUID) is **updated in place**
   (same ID, URL and alias), files are copied or replaced.

The local copy wins: pushing a page overwrites edits made to that page on production.
It only works between sites that share UUIDs, which is why production starts as a copy
of the local database (below).

## One-time Droplet setup

Ubuntu 24.04, 2 GB RAM or more. As root:

```sh
# Packages: nginx, PHP 8.4 (ondrej PPA, same version as DDEV and CI), MariaDB, tools.
add-apt-repository -y ppa:ondrej/php
apt update
apt install -y nginx mariadb-server rsync unzip curl certbot python3-certbot-nginx \
  php8.4-fpm php8.4-cli php8.4-mysql php8.4-gd php8.4-xml php8.4-mbstring php8.4-curl \
  php8.4-zip php8.4-intl php8.4-bcmath php8.4-opcache php8.4-apcu

# Deploy user (GitHub Actions and push-content log in as this user).
adduser --disabled-password --gecos "" deploy
usermod -aG www-data deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
# Add the public key(s) to /home/deploy/.ssh/authorized_keys (owner deploy, mode 600).

# Directories: deploy owns everything, group www-data (setgid: new files inherit it).
# The base folder too: deploy switches the `current` symlink in it.
install -d -o deploy -g www-data -m 2755 /var/www/marcus-blog /var/www/marcus-blog/{releases,incoming}
install -d -o deploy -g www-data -m 2775 /var/www/marcus-blog/shared /var/www/marcus-blog/shared/files  # PHP writes files
install -d -o deploy -g www-data -m 2750 /var/www/marcus-blog/shared/oauth-keys                       # PHP only reads
install -d -o deploy -g deploy   -m 700  /var/www/marcus-blog/backups                                 # database dumps

# Let deploy reload PHP-FPM (clears OPcache after a switch), nothing else.
echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.4-fpm' > /etc/sudoers.d/deploy-php
chmod 440 /etc/sudoers.d/deploy-php
```

**Database**

```sh
mysql -e "CREATE DATABASE marcus_blog CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
  CREATE USER 'marcus_blog'@'localhost' IDENTIFIED BY '<strong password>';
  GRANT ALL ON marcus_blog.* TO 'marcus_blog'@'localhost';"
```

**Environment**: copy `scripts/deploy/env.example` to `/var/www/marcus-blog/shared/.env`
and fill it in (`chown deploy:www-data`, `chmod 640`). PHP-FPM reads the same file:

```sh
systemctl edit php8.4-fpm      # add:
#   [Service]
#   EnvironmentFile=/var/www/marcus-blog/shared/.env
sed -i 's/^;\?clear_env = .*/clear_env = no/' /etc/php/8.4/fpm/pool.d/www.conf
echo 'umask 002' >> /home/deploy/.profile   # files written by drush stay group-writable
systemctl restart php8.4-fpm
```

**nginx**: `scripts/deploy/nginx.conf.example` → `/etc/nginx/sites-available/marcus-blog`,
set `server_name`, enable it, `nginx -t && systemctl reload nginx`, then
`certbot --nginx -d www.example.com -d example.com`.

**Cron** (as deploy, `crontab -e`): sitemap regeneration and Drupal housekeeping.

```
*/15 * * * * set -a; . /var/www/marcus-blog/shared/.env; cd /var/www/marcus-blog/current && vendor/bin/drush cron -q
```

### https behind Varnish

TLS ends in front of Varnish, so Apache and PHP see plain http, and Drupal writes `http://` into
absolute URLs (RSS, canonical, `og:url`) unless the web server tells PHP the request was https.
(Setting `$_SERVER['HTTPS']` in settings.php does not work in Drupal 11: the request is built first.)
Pick one:

- **Apache** (simplest; every visitor arrives over https, plain http is redirected before
  Varnish): in the site's `<VirtualHost>` add `SetEnv HTTPS on`, then
  `apachectl configtest && systemctl reload apache2`.
- **Reverse proxy headers**: if the TLS front sends `X-Forwarded-Proto: https` through Varnish,
  put the address Varnish connects to Apache from in `shared/.env`
  (`DRUPAL_REVERSE_PROXY_ADDRESSES=127.0.0.1`) and restart PHP. Drupal then also logs the
  visitors' real IPs.

Then empty Varnish (`varnishadm "ban req.url ~ ."`) and check:
`curl -s https://<host>/rss.xml | grep atom:link` shows `https://`.

## GitHub

Repository → Settings → Secrets and variables → Actions:

| Secret | Value |
|---|---|
| `DEPLOY_HOST` | Droplet IP or host name |
| `DEPLOY_USER` | `deploy` |
| `DEPLOY_SSH_KEY` | a private key whose public key is in `/home/deploy/.ssh/authorized_keys` (make a dedicated one: `ssh-keygen -t ed25519 -f marcus-blog-deploy -N ""`) |
| `DEPLOY_KNOWN_HOSTS` | output of `ssh-keyscan <host>` |

Optional variables: `DEPLOY_PATH` (default `/var/www/marcus-blog`), `DEPLOY_SSH_PORT`.
The workflow runs in the `production` environment, so you can add required reviewers there.

## First deploy

Production starts as a copy of the local site (same content UUIDs, so content pushes
update the right pages):

```sh
# Locally
ddev export-db --file=/tmp/marcus-blog.sql.gz
rsync -az --exclude={css,js,php,styles} web/sites/default/files/ deploy@<host>:/var/www/marcus-blog/shared/files/
scp /tmp/marcus-blog.sql.gz deploy@<host>:/tmp/

# On the Droplet (as deploy)
gunzip -c /tmp/marcus-blog.sql.gz | mysql -u marcus_blog -p marcus_blog && rm /tmp/marcus-blog.sql.gz
```

Then tag the first release (`git tag v1.0.0 && git push origin v1.0.0`). After it is live,
on the Droplet:

```sh
set -a; . /var/www/marcus-blog/shared/.env; cd /var/www/marcus-blog/current
vendor/bin/drush simple-oauth:generate-keys /var/www/marcus-blog/shared/oauth-keys  # MCP OAuth keys
# The keys are created 600 for deploy; PHP-FPM runs as www-data and must read them.
chgrp www-data /var/www/marcus-blog/shared/oauth-keys/*.key
chmod 640 /var/www/marcus-blog/shared/oauth-keys/private.key
chmod 644 /var/www/marcus-blog/shared/oauth-keys/public.key
vendor/bin/drush user:password admin '<new strong password>'                         # not admin/admin
vendor/bin/drush user:password marcus '<new strong password>'
vendor/bin/drush cr
```

Check: `curl -sI https://<host>/mcp` answers 401 (asks for a login), not 500. The keys stay in
`shared/` across releases; generating new ones logs out every MCP client.

## Restore by hand

```sh
cd /var/www/marcus-blog && set -a && . shared/.env && set +a
ls backups/                                                    # pick one
gunzip -c backups/<backup>/db.sql.gz | current/vendor/bin/drush --root=current sql:cli
tar -xzf backups/<backup>/files.tar.gz -C shared               # files
ln -sfn releases/<previous tag> current && sudo systemctl reload php8.4-fpm   # code
```
