Как настроить production-деплой на shared-хостинг с атомарным swap, автооткатом и уведомлениями в Telegram.
Разбираю на реальном примере WordPress + WooCommerce + headless + VitePress-лендинг и документация. Приёмы работают для любого PHP-проекта на shared-хостинге с SSH-доступом.
Классика жанра
Сначала — как обычно делают на shared-хостинге:
- Правишь файлы локально.
- Открываешь FileZilla, тянешь изменения на сервер по FTP.
- Сайт ломается.
- Никто не знает, что откатывать. Бэкап — «вчерашний, наверное».
С деплоем через GitHub Actions:
- Push в
master— триггер. - На runner’е:
composer install, сборка VitePress, один архивdeploy-package.tar.gz. - Загрузка по SSH на сервер.
- Бэкап → атомарный swap → health-check → маркер успеха.
- На любой ошибке — откат из последнего бэкапа за секунды.
- Telegram-сообщение с коммитом, автором и статусом.
От push до нового кода на проде — 2–3 минуты. Откат в худшем случае — секунды.
Архитектура
Поток данных
git push origin master
↓
GitHub Actions runner
↓
1. composer install --no-dev
2. npm ci --prefix landing
3. npm run build --prefix landing
4. tar czf deploy-package.tar.gz deploy-package/
5. sshpass rsync → server:$DEPLOY_PATH/.deploy-new.tar.gz
6. sshpass rsync scripts/deploy-remote.sh → server
↓
Сервер (Beget)
↓
bash .deploy-remote.sh
1. Sanity: архив + wp-config.php на месте
2. rsync public_html → backups/release-<ts>/
3. tar xzf архива → public_html/.new-<ts>/
4. Проверка критичных файлов
5. rsync .new-<ts>/ → public_html/
6. Симлинк /docs/ → landing/dist/docs/
7. WP cache flush
8. Health-check: главная, /docs/, /wp-login.php, V2-titles, статика
9. Маркер успешного деплоя
↓
Telegram: «✅ Успешно» или «❌ Провал — откат выполнен»
↓
Cleanup: удалить .deploy-new.tar.gz и .deploy-remote.sh
Что лежит на сервере
/home/b/<user>/<site>/
├── public_html/ ← DocumentRoot
│ ├── wp/ ← WordPress core
│ ├── wp-content/...
│ ├── vendor/ ← Composer
│ ├── landing/dist/ ← VitePress-сборка
│ ├── docs → landing/dist/docs ← симлинк
│ ├── index.php, .htaccess, composer.json
│ └── wp-config.php ← только на сервере, не в архиве
├── backups/ ← 3 последних бэкапа
│ ├── release-20260619_125556/
│ ├── release-20260619_130033/
│ └── .last-successful-deploy
└── .deploy-new.tar.gz ← служебный, удаляется после деплоя
Секреты GitHub
| Имя | Значение |
|---|---|
BEGET_HOST |
SSH-хост (например, ocelot.beget.com) |
BEGET_USERNAME |
SSH-пользователь |
BEGET_PASSWORD |
SSH-пароль |
BEGET_PORT |
SSH-порт (по умолчанию 22) |
TELEGRAM_CHAT_ID |
ID чата для уведомлений |
TELEGRAM_BOT_TOKEN |
Токен бота |
Код
.github/workflows/deploy.yml
name: Deploy to Beget
on:
push:
branches: [master]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
env:
PHP_VERSION: "8.3"
NODE_VERSION: "18"
DEPLOY_HOST: ${{ secrets.BEGET_HOST }}
DEPLOY_USER: ${{ secrets.BEGET_USERNAME }}
DEPLOY_PASSWORD: ${{ secrets.BEGET_PASSWORD }}
DEPLOY_PORT: ${{ secrets.BEGET_PORT || '22' }}
DEPLOY_PATH: /home/b/bo3gyx78/woo2iiko.rwsite.ru
jobs:
build-and-deploy:
name: Build, deploy and verify
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: false
fetch-depth: 1
clean: true
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ env.PHP_VERSION }}
extensions: mbstring, xml, ctype, iconv, intl, pdo_mysql, curl, zip, gd, redis
coverage: none
tools: composer:2
- name: Cache Composer downloads
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ runner.os }}-${{ hashFiles('composer.json', 'composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-
- name: Install Composer dependencies
run: |
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: landing/package-lock.json
- name: Install Node dependencies
run: npm ci --prefix landing
- name: Build VitePress
run: |
npm run build --prefix landing
test -f landing/dist/index.html
test -f landing/dist/docs/index.html
- name: Build deployment archive
run: |
set -e
rm -rf deploy-package deploy-package.tar.gz
mkdir -p deploy-package
cp -r wp deploy-package/
mkdir -p deploy-package/wp-content
cp -r wp-content/mu-plugins wp-content/plugins wp-content/themes wp-content/languages deploy-package/wp-content/
cp -r vendor deploy-package/
mkdir -p deploy-package/landing/dist
cp -r landing/dist/. deploy-package/landing/dist/
for d in assets libs screenshots images docs-v1; do
[ -d "landing/dist/$d" ] && cp -r "landing/dist/$d" "deploy-package/$d"
done
cp docs.htaccess deploy-package/landing/dist/docs/.htaccess
cp index.php composer.json composer.lock .htaccess deploy-package/
find deploy-package -type d -exec chmod 755 {} ;
find deploy-package -type f -exec chmod 644 {} ;
tar -czf deploy-package.tar.gz -C deploy-package .
echo "📦 Archive contents:"
tar -tzf deploy-package.tar.gz | awk -F/ '{print $1}' | sort -u | head -20
echo "📦 Size: $(du -h deploy-package.tar.gz | cut -f1)"
- name: Upload archive to server
env:
SSHPASS: ${{ env.DEPLOY_PASSWORD }}
run: |
set -e
if ! command -v sshpass >/dev/null 2>&1; then
sudo apt-get update -qq && sudo apt-get install -y -qq sshpass
fi
REMOTE_PATH="$DEPLOY_PATH/.deploy-new.tar.gz"
REMOTE_SCRIPT="$DEPLOY_PATH/.deploy-remote.sh"
sshpass -e rsync -avz --progress
-e "ssh -o StrictHostKeyChecking=no -p $DEPLOY_PORT"
deploy-package.tar.gz
"$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_PATH"
sshpass -e rsync -avz
-e "ssh -o StrictHostKeyChecking=no -p $DEPLOY_PORT"
scripts/deploy-remote.sh
"$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_SCRIPT"
LOCAL_SIZE=$(wc -c < deploy-package.tar.gz)
REMOTE_SIZE=$(sshpass -e ssh -o StrictHostKeyChecking=no
-p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST"
"wc -c < '$REMOTE_PATH'")
if [ "$LOCAL_SIZE" != "$REMOTE_SIZE" ]; then
echo "❌ Size mismatch: local $LOCAL_SIZE, remote $REMOTE_SIZE"
exit 1
fi
echo "✅ Archive uploaded and verified"
- name: Deploy, health-check and auto-rollback
uses: appleboy/ssh-action@v1.0.3
env:
DEPLOY_PATH: ${{ env.DEPLOY_PATH }}
COMMIT_SHA: ${{ github.sha }}
with:
host: ${{ env.DEPLOY_HOST }}
username: ${{ env.DEPLOY_USER }}
password: ${{ env.DEPLOY_PASSWORD }}
port: ${{ env.DEPLOY_PORT }}
command_timeout: 20m
envs: DEPLOY_PATH,COMMIT_SHA
script: |
bash "$DEPLOY_PATH/.deploy-remote.sh"
- name: Cleanup
if: always()
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ env.DEPLOY_HOST }}
username: ${{ env.DEPLOY_USER }}
password: ${{ env.DEPLOY_PASSWORD }}
port: ${{ env.DEPLOY_PORT }}
script: |
rm -f "$DEPLOY_PATH/.deploy-new.tar.gz"
rm -f "$DEPLOY_PATH/.deploy-remote.sh"
rm -rf "$DEPLOY_PATH/public_html/.new-"* 2>/dev/null || true
echo "🧹 Cleanup done"
notify:
name: Notify Telegram
needs: build-and-deploy
if: always()
runs-on: ubuntu-latest
steps:
- name: Telegram notification
uses: appleboy/telegram-action@v0.1.0
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
🚀 Woo2iiko Deploy
${{ needs.build-and-deploy.result == 'success' && '✅ Успешно' || '❌ Провал — выполнен auto-rollback' }}
📝 Коммит: `${{ github.sha }}`
👤 Автор: ${{ github.actor }}
🌿 Ветка: ${{ github.ref_name }}
🔗 https://woo2iiko.rwsite.ru/
📚 https://woo2iiko.rwsite.ru/docs/
continue-on-error: true
scripts/deploy-remote.sh
#!/usr/bin/env bash
# Запускается на сервере. Читает DEPLOY_PATH и COMMIT_SHA из env.
set -e
PUB="$DEPLOY_PATH/public_html"
BACKUP_DIR="$DEPLOY_PATH/backups"
ARCHIVE="$DEPLOY_PATH/.deploy-new.tar.gz"
COMMIT_SHA="${COMMIT_SHA:-unknown}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
NEW_DIR="$PUB/.new-$TIMESTAMP"
BACKUP_PATH="$BACKUP_DIR/release-$TIMESTAMP"
cleanup_and_rollback() {
local rc=$?
echo "🧹 Cleanup (exit $rc)"
rm -rf "$PUB"/.new-* 2>/dev/null || true
if [ $rc -ne 0 ]; then
local last_backup
last_backup=$(ls -1td "$BACKUP_DIR"/release-* 2>/dev/null | head -1 || true)
if [ -n "$last_backup" ] && [ -d "$last_backup" ]; then
echo "🔄 Auto-rollback to: $last_backup"
rsync -rlD --delete
--no-group --no-perms --no-owner
--exclude='wp-config.php'
--exclude='wp-content/uploads/'
--exclude='wp-content/cache/'
--exclude='.new-*'
"$last_backup/" "$PUB/" 2>&1 || echo "rsync rollback failed"
else
echo "⚠️ No backup available"
fi
fi
exit $rc
}
trap cleanup_and_rollback EXIT
# === Sanity checks ===
[ -f "$ARCHIVE" ] || { echo "❌ Archive not found"; exit 1; }
[ -f "$PUB/wp-config.php" ] || { echo "❌ wp-config.php missing"; exit 1; }
# === Backup ===
mkdir -p "$BACKUP_DIR"
rsync -rlD --no-group --no-perms --no-owner
--exclude='.new-*'
--exclude='wp-content/cache/'
--exclude='wp-content/upgrade/'
"$PUB/" "$BACKUP_PATH/"
# Ротация: оставляем 3 последних
cd "$BACKUP_DIR"
ls -1t | grep '^release-' | tail -n +4 | while IFS= read -r old; do
rm -rf "$BACKUP_DIR/$old"
done
cd - >/dev/null
# === Атомарный swap ===
mkdir -p "$NEW_DIR"
tar -xzf "$ARCHIVE" -C "$NEW_DIR"
for f in index.php landing/dist/index.html landing/dist/docs/index.html; do
[ -e "$NEW_DIR/$f" ] || { echo "❌ Missing: $f"; exit 1; }
done
for d in assets libs screenshots images; do
[ -d "$NEW_DIR/$d" ] || { echo "❌ Missing dir: $d"; exit 1; }
done
rsync -rlD --delete
--no-group --no-perms --no-owner
--exclude='.new-*'
--exclude='wp-config.php'
--exclude='wp-content/uploads/'
--exclude='wp-content/cache/'
"$NEW_DIR/" "$PUB/"
rm -rf "$NEW_DIR"
# === Симлинк /docs/ ===
DOCS_LINK="$PUB/docs"
DOCS_TARGET="landing/dist/docs"
if [ -L "$DOCS_LINK" ]; then
[ "$(readlink "$DOCS_LINK")" = "$DOCS_TARGET" ] || {
rm -f "$DOCS_LINK"
ln -s "$DOCS_TARGET" "$DOCS_LINK"
echo "🔗 Symlink updated"
}
elif [ -e "$DOCS_LINK" ]; then
mv "$DOCS_LINK" "$PUB/docs.archived.$TIMESTAMP"
ln -s "$DOCS_TARGET" "$DOCS_LINK"
echo "🔗 Archived old docs, symlink created"
else
ln -s "$DOCS_TARGET" "$DOCS_LINK"
echo "🔗 Symlink created"
fi
ls -dt "$PUB"/docs.archived.* 2>/dev/null | tail -n +3 | xargs -r rm -rf
# === Cache flush ===
if command -v wp &>/dev/null; then
wp cache flush --allow-root 2>/dev/null || true
wp redis flush --allow-root 2>/dev/null || true
wp rewrite flush --allow-root 2>/dev/null || true
wp language core update --allow-root 2>/dev/null || true
wp language plugin update --all --allow-root 2>/dev/null || true
wp language theme update --all --allow-root 2>/dev/null || true
fi
rm -rf "$PUB/wp-content/cache/"* 2>/dev/null || true
touch "$PUB/index.php" "$PUB/wp/index.php" 2>/dev/null || true
# === Health-check ===
sleep 3
echo "🔎 Health-check..."
check_url() {
local url="$1" name="$2"
local code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$url" || echo "000")
[ "$code" = "200" ] || { echo "❌ $name: HTTP $code"; return 1; }
echo "✅ $name: HTTP 200"
}
check_url "https://woo2iiko.rwsite.ru/" "Главная"
check_url "https://woo2iiko.rwsite.ru/docs/" "Документация"
check_url "https://woo2iiko.rwsite.ru/wp/wp-login.php" "WordPress"
DOCS=$(curl -s --max-time 10 "https://woo2iiko.rwsite.ru/docs/install/purchase" || true)
echo "$DOCS" | grep -q "<title>Покупка лицензии — woo2iiko</title>" || {
echo "❌ V2 docs: /docs/install/purchase wrong title"; exit 1;
}
echo "✅ V2 docs: /docs/install/purchase OK"
DOCS_ROOT=$(curl -s --max-time 10 "https://woo2iiko.rwsite.ru/docs/" || true)
echo "$DOCS_ROOT" | grep -q "<title>Документация Woo2iiko — woo2iiko</title>" || {
echo "❌ V2 docs root wrong title"; exit 1;
}
echo "✅ V2 docs root OK"
CTYPE=$(curl -sI --max-time 10 "https://woo2iiko.rwsite.ru/screenshots/import.jpeg"
| awk -F': ' 'tolower($1) == "content-type" {print $2}' | tr -d 'r' | head -1)
[ "$CTYPE" = "image/jpeg" ] || {
echo "❌ Screenshots: '$CTYPE' (expected image/jpeg)"; exit 1;
}
echo "✅ Screenshots: image/jpeg"
echo "$COMMIT_SHA" > "$BACKUP_DIR/.last-successful-deploy"
echo "✅ Deployed: $COMMIT_SHA"
trap - EXIT
exit 0
Грабли, на которые наступают почти все
Проблемы с которыми я столкнулся и нюансы shred хостинга.
1. chroot SFTP на Beget
Симптом: архив загружен, но скрипт не находит его по пути /tmp/deploy-new.tar.gz.
Причина: SFTP на Beget работает в chroot. target: "/tmp/..." в SFTP маппится в /home/b/<user>/tmp/..., а в обычной SSH-сессии /tmp — реальный системный /tmp. Пути не совпадают. То же самое с ~.
Решение: Не используйте appleboy/scp-action (SFTP). Используйте sshpass + rsync — они работают через SSH-протокол напрямую, без SFTP-подсистемы:
sshpass -e rsync -avz --progress
-e "ssh -o StrictHostKeyChecking=no -p $DEPLOY_PORT"
deploy-package.tar.gz
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/.deploy-new.tar.gz"
Пароль передаётся через SSHPASS env, чтобы не светиться в ps.
Проверка после загрузки: wc -c локального и удалённого файла должны совпасть. Если нет — fail fast, не доходя до деплоя.
2. rsync -a падает на chgrp
Симптом:
rsync: chgrp "/home/b/.../public_html/." failed: Operation not permitted (1)
rsync error: some files/attrs were not transferred (code 23)
Деплой падает, и rollback тоже падает с той же ошибкой.
Причина: rsync -a = rsync -rlptgoD. На shared hosting нет прав на chgrp (и chown/chmod для чужих файлов). rsync пытается сохранить оригинальные атрибуты — fail.
Решение: rsync -rlD --no-group --no-perms --no-owner вместо -a. Это -a минус -pgo. umask при создании файлов задаст разумные права автоматически.
Применить ко всем трём rsync-вызовам: backup, swap, rollback.
Что не делать: Не вызывайте find ... -exec chmod 644 {} ; после rsync. На Beget 0600 — намеренная защита от других пользователей хостинга. Apache работает от вашего пользователя, читает 0600 нормально. chmod 644 сломает изоляцию и не решит проблему.
3. Права в архиве
tar -xzf распаковывает файлы с правами 0600 (только владелец). Apache на Beget от вашего пользователя — читает нормально. Но если Nginx проксирует с других IP — может не отдавать Content-Type (HTTP 200 с пустым телом).
Решение: Задать права до архивации:
find deploy-package -type d -exec chmod 755 {} ;
find deploy-package -type f -exec chmod 644 {} ;
tar -czf deploy-package.tar.gz -C deploy-package .
На сервере tar может перезаписать права на 0600 из-за политики безопасности. Поэтому --no-perms в rsync (см. граблю №2).
4. Проверка статики awk -F': ' с лишним двоеточием
Симптом: health-check на статику падает:
❌ Screenshots content-type: '' (expected image/jpeg)
curl с обычного компа возвращает image/jpeg нормально.
Причина: В коде было:
awk -F': ' 'tolower($1) == "content-type:" {print $2}'
-F': ' уже отрезал двоеточие с пробелом. Для строки content-type: image/jpeg:
$1="content-type"(без двоеточия)$2="image/jpeg"
Сравнение == "content-type:" с двоеточием никогда не срабатывает. Парсер всегда возвращал пустую строку.
Решение:
awk -F': ' 'tolower($1) == "content-type" {print $2}' # без ':'
Или надёжнее — curl сам умеет:
CTYPE=$(curl -sI --max-time 10 -o /dev/null -w '%{content_type}' "https://...")
Атомарный swap
Никогда не меняй public_html/ напрямую. Вместо этого:
- Создать
public_html/.new-<TIMESTAMP>/(скрытая директория). - Распаковать в неё архив.
- Проверить критичные файлы (
index.php,landing/dist/index.html,landing/dist/docs/index.html). rsync --deleteиз.new-<ts>/вpublic_html/.- Удалить
.new-<ts>/.
Только теперь public_html/ отдаёт новую версию.
Почему это работает:
- Проверка критичных файлов до замены. Если архив битый — fail до того, как сайт что-то заметит.
--deleteубирает файлы, которых больше нет в новой версии. Без него старые JS-бандлы и CSS накапливались бы.set -e+trapгарантируют: при любой ошибке — cleanup и rollback.
Время недоступности: При rsync nginx продолжает отдавать старую версию, пока новые файлы пишутся. После завершения rsync — отдаёт новую. Между ними — десятые доли секунды на смену inode-ов. Незаметно для пользователя.
Auto-rollback
trap ловит EXIT — любой выход из скрипта. Если код возврата не 0, запускается rollback из последнего backups/release-*/.
trap - EXIT в конце скрипта (после успешного health-check) убирает ловушку, чтобы при exit 0 rollback не сработал.
Что ловится:
tar -xzfупал (битый архив).- Проверка критичных файлов не прошла.
rsyncупал.- Health-check: HTTP не 200.
Content-Typeне тот.- Любая команда с
set -e.
Что не ловится (и правильно):
wp cache flush— обёрнут в|| true, чтобы не валить деплой на проблемах с WP-CLI.- Команды очистки кэша —
|| true. rsync rollback failed—|| echoбез exit-кода.
Cleanup-операции не должны мешать деплою.
Health-check
Шесть проверок после деплоя. Если любая падает — exit 1 → trap → rollback.
- Главная страница — HTTP 200.
- Документация — HTTP 200.
- WordPress login — HTTP 200.
- V2-документация по title (
<title>Покупка лицензии</title>). - V2-документация root по title.
- Статика —
Content-Type: image/jpeg.
Почему именно эти:
- Три точки входа — главная,
/docs/,/wp-login.php. Если хоть одна 200 — сайт жив. - V2-titles ловят случаи, когда отдаётся 200, но старая V1-сборка. На VitePress cleanUrls это бывает, если nginx проксирует запрос до rewrite.
Content-Typeловит случаи, когда nginx отдаёт HTML вместо изображения (например, при ошибке symlink).
sleep 3 перед health-check — Apache может 1–2 секунды кэшировать ответы на старые inode-ы.
Telegram-уведомления
- name: Telegram notification
uses: appleboy/telegram-action@v0.1.0
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
🚀 Woo2iiko Deploy
${{ needs.build-and-deploy.result == 'success' && '✅ Успешно' || '❌ Провал — выполнен auto-rollback' }}
📝 Коммит: `${{ github.sha }}`
👤 Автор: ${{ github.actor }}
🌿 Ветка: ${{ github.ref_name }}
continue-on-error: true
if: always()— уведомление приходит в любом исходе.continue-on-error: true— если Telegram недоступен, деплой не искажается.- Chat ID для приватных чатов включает минус:
-1001234567890.
Concurrency
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
Если запушили два коммита подряд в master, второй отменит первый и пойдёт по свежему коду. Без этого — race condition: оба деплоя пишут в backups/release-*, оба делают swap, второй затирает первый.
Безопасность
permissions: contents: read— минимум прав.SSHPASSenv — пароль не светится в логах иps.continue-on-error: trueна Telegram — недоступность Telegram не валит деплой.-rlD --no-perms --no-owner --no-groupв rsync — не трогаем чужие права на shared hosting.
Что улучшить
- Артефакты через
actions/upload-artifact— ручной откат на любой SHA без пересборки. - E2E smoke-тесты (Playwright): пройти по главной, нажать «Купить», проверить checkout.
- Параллельные health-check — сейчас последовательно, можно в фоне.
- Shellcheck для
deploy-remote.shв CI. - Уведомление о начале деплоя — приходит ещё до окончания.
Чеклист перед первым деплоем
- Секреты в GitHub:
BEGET_HOST,BEGET_USERNAME,BEGET_PASSWORD,BEGET_PORT,TELEGRAM_CHAT_ID,TELEGRAM_BOT_TOKEN. -
DEPLOY_PATHв workflow совпадает с реальным путём на сервере. -
wp-config.phpуже лежит на сервере вpublic_html/. - SSH-доступ работает:
ssh $BEGET_USERNAME@$BEGET_HOSTзаходит по паролю. -
tar,rsync,curlесть на сервере (на Beget — есть). - Тестовый деплой через
workflow_dispatchпрошёл успешно. - Telegram-сообщение пришло.
- После деплоя
ls -la backups/содержитrelease-<ts>/. - Сайт открывается,
/docs/отдаёт V2, статика с правильнымContent-Type.
Полезные команды на сервере
# Список бэкапов
ls -la /home/b/<user>/<site>/backups/
# Маркер последнего успешного деплоя
cat /home/b/<user>/<site>/backups/.last-successful-deploy
# Симлинк /docs/
readlink /home/b/<user>/<site>/public_html/docs
# Тест curl с сервера
curl -sI https://example.com/screenshots/import.jpeg