# Can I self-host Hootsuite?

**YES, BUT** — it's called Mixpost. ONE WEEKEND setup · ~4 hours to running · 2 GB RAM minimum · $199/mo you stop paying ($2,388/yr on the Professional plan).

Mixpost authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/hootsuite/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Mixpost Lite 2.6.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they
answer. It becomes `APP_URL` and the host inside every OAuth callback URL they register at X
and at Meta, so moving it later means editing each of those apps by hand. Its A record must
already point at this server.

Mixpost Lite needs 2048 MB of RAM available and 10 GB free on /srv. Upstream's
troubleshooting page asks for 4 GB, aimed mostly at its Enterprise edition; three containers
clear 2 GB, and video is the spike, because the image bundles ffmpeg. All three images are
amd64 and arm64.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop.
If `dig +short` prints nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/mixpost /srv/mixpost/backups
sudo install -d -m 750 /srv/mixpost/storage /srv/mixpost/logs
sudo install -d -m 700 /srv/mixpost/mysql /srv/mixpost/redis
ls -la /srv/mixpost
```

Assert: five directories. `backups` owned by the login user; `mysql` and `redis` at mode
`700` owned by root, because both images chown their own data directory on first start and
one already chowned elsewhere makes MySQL refuse to initialise. `storage` and `logs` stay
root-owned too: the Mixpost container chowns its application directory to www-data at every
start, and these two sit inside it once mounted.

## 3. Secrets

Four, all generated here. Print none of them, and keep them out of your summary and every
log line.

```bash
umask 077
cat > /srv/mixpost/.env <<EOF
MIXPOST_DOMAIN=<DOMAIN>
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/mixpost/.env
umask 022
ls -l /srv/mixpost/.env
```

Assert: mode `-rw-------`. `APP_KEY` is a Laravel key for AES-256-CBC, 32 random bytes in
base64, and it encrypts every stored provider secret and social token. Upstream offers a web
page that generates one; this makes its own, because a key from somebody else's website is a
key somebody else saw. `ADMIN_PASSWORD` reaches no container: step 7 sets it on the account
the image creates. Compose reads this file from the working directory, so run everything
from /srv/mixpost.

## 4. compose.yml and the trusted-proxy file

Two files. The config file first: a bind mount whose host path is missing makes Docker
create a directory instead.

```bash
cat > /srv/mixpost/trustedproxy.php <<'EOF'
<?php
// Mixpost Lite · trusted proxies. Authored by caniselfhostit from the image's
// vendor/laravel/framework/src/Illuminate/Http/Middleware/TrustProxies.php,
// which trusts nothing unless the application sets a proxy list, which this
// image does not, or config('trustedproxy.proxies') exists, which is this
// file. Without it Laravel reads Caddy's plain http connection and hands the
// browser http:// URLs on an https page, the Ziggy route table the dashboard
// drives itself from included, and browsers block those.
//
// '*' trusts the address that connected, which behind a loopback-only
// published port is only ever the Caddy on this host.

return [
    'proxies' => '*',
];
EOF
chmod 644 /srv/mixpost/trustedproxy.php
```

Then the three services:

```bash
cat > /srv/mixpost/compose.yml <<'EOF'
# Mixpost Lite · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   docker install ...... https://docs.mixpost.app/lite/installation/docker
#   variable reference .. https://docs.mixpost.app/lite/configuration/environment-variables
#   troubleshooting ..... https://docs.mixpost.app/troubleshooting
#
# Three services. The Mixpost image is one Ubuntu container running nginx,
# PHP-FPM, the Horizon queue worker and cron under supervisord; MySQL holds the
# posts, accounts and encrypted provider credentials; Redis is what Horizon
# queues on. Upstream's compose file names mysql/mysql-server, a Docker Hub
# repository whose newest tag is 8.0.32 from January 2023, so this pins the
# official mysql image on the 8.4 LTS line, and every image carries a tag and
# a digest. Digests read 2026-08-14, all amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: mixpost

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      # The image will not initialise without a root variable; nothing here
      # connects as root.
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: mixpost
      MYSQL_USER: mixpost
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/mixpost/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", 'mysqladmin ping -h 127.0.0.1 -u "$$MYSQL_USER" -p"$$MYSQL_PASSWORD" --silent']
      interval: 10s
      retries: 18
    # No `ports:` at all: 3306 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/mixpost/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  mixpost:
    image: inovector/mixpost:v2.6.0@sha256:90cf94cec73dcaf87989d30b0de7a84b0625ff06797ba61c8ecb54e8fe1e10c4
    restart: unless-stopped
    environment:
      APP_NAME: Mixpost
      # Every stored provider secret and social token is encrypted with this.
      APP_KEY: ${APP_KEY}
      APP_DEBUG: "false"
      APP_URL: "https://${MIXPOST_DOMAIN}"
      DB_HOST: mysql
      DB_PORT: "3306"
      DB_DATABASE: mixpost
      DB_USERNAME: mixpost
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: "6379"
      MIXPOST_DISK: public
    volumes:
      # The media library, on the "public" disk, then the application log.
      - /srv/mixpost/storage:/var/www/html/storage/app
      - /srv/mixpost/logs:/var/www/html/storage/logs
      # Why this third file exists is written inside it. It has to exist on the
      # host first, or Docker creates a directory at that path.
      - /srv/mixpost/trustedproxy.php:/var/www/html/config/trustedproxy.php
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8197.
      - "127.0.0.1:8197:80"
    # If Facebook connections later time out, upstream's troubleshooting page
    # says Meta refuses the container over IPv6: add a `sysctls:` entry here,
    # net.ipv6.conf.all.disable_ipv6=1.
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
EOF
cd /srv/mixpost && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK` and nothing else. An unset-variable warning means step 3 did not write
.env into /srv/mixpost.

## 5. Caddy and TLS

Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-mixpost
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Mixpost Lite · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.mixpost.app/lite/installation/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. It is also
# MIXPOST_DOMAIN in .env and the host inside every OAuth callback URL you
# register at X and at Meta.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting an account bounces out to a provider.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8197 is the loopback port compose publishes here, not a container port,
	# and not open in the firewall. Caddy sets X-Forwarded-Proto on the way
	# through, and trustedproxy.php is what tells the app to believe it.
	reverse_proxy 127.0.0.1:8197
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-mixpost, reload, and report what it objected to.

## 6. Firewall

Two ports open, both Caddy's, idempotent:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the way in, 443/udp is
HTTP/3. 8197 is on 127.0.0.1 and compose publishes no host port for MySQL or Redis. Assert:
`ufw status verbose` prints `Status: active`, those three rules, and nothing for 8197, 3306
or 6379.

## 7. Start and verify

MySQL initialises its data directory, then the Mixpost container waits for it and runs every
Laravel migration, so the first start is the slow one.

```bash
cd /srv/mixpost
docker compose pull
docker compose up -d
for i in $(seq 1 42); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8197/mixpost/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/mixpost/login | grep -c 'Log in'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/horizon
curl -sS -o /dev/null -w '%{redirect_url}\n' https://<DOMAIN>/mixpost
```

Assert all four, printing what you received. The loop ends on `200`. The grep prints `1`,
the sign-in button. `/horizon` prints `403`: the Laravel queue dashboard this image mounts
refuses anyone not signed in. The last prints `https://<DOMAIN>/mixpost/login`, and the
`https` is the point: an `http://` target means trustedproxy.php did not load, and the
dashboard will break in a browser though curl is content. On any miss, stop, run
`docker compose logs --tail 40 mixpost` and `docker compose logs --tail 20 mysql`, and name
the cause: a MySQL container stuck below healthy points at step 3, and a curl error instead
of a number on the two https lines is a certificate Caddy has not finished issuing for a
minutes-old A record.

Now close the door the image leaves open. `start.sh` in the container runs
`mixpost-auth:create --admin` on every start where no `admin@example.com` row exists, with
the password upstream prints in its install guide. Sign in with it once over loopback,
replace it, and prove the published one is dead:

```bash
cd /srv/mixpost
umask 077
B=http://127.0.0.1:8197
J=/srv/mixpost/.jar
N=/srv/mixpost/.newpw
tok() { sed -n 's/.*name="csrf-token" content="\([^"]*\)".*/\1/p'; }
printf '%s' "$(grep '^ADMIN_PASSWORD' /srv/mixpost/.env | cut -d= -f2-)" > $N
rm -f $J
T=$(curl -sS -c $J $B/mixpost/login | tok)
curl -sS -b $J -c $J -o /dev/null -w 'seeded %{redirect_url}\n' -X POST $B/mixpost/login --data-urlencode "_token=$T" --data-urlencode 'email=admin@example.com' --data-urlencode 'password=changeme'
T=$(curl -sS -b $J -c $J $B/mixpost/profile | tok)
curl -sS -b $J -c $J -o /dev/null -w 'rotate %{http_code}\n' -X PUT $B/mixpost/profile/password --data-urlencode "_token=$T" --data-urlencode 'current_password=changeme' --data-urlencode "password@$N" --data-urlencode "password_confirmation@$N"
rm -f $J
T=$(curl -sS -c $J $B/mixpost/login | tok)
curl -sS -b $J -c $J -o /dev/null -w 'published %{redirect_url}\n' -X POST $B/mixpost/login --data-urlencode "_token=$T" --data-urlencode 'email=admin@example.com' --data-urlencode 'password=changeme'
rm -f $J $N
umask 022
```

Assert three printed lines. `seeded http://127.0.0.1:8197/mixpost` is the published password
working, the door being closed. `rotate 302`. `published
http://127.0.0.1:8197/mixpost/login` is that credential bounced back, which is the closure.
If the third still ends in `/mixpost`, stop and have the user change the password in the
browser at once. A running container is not success.

STOP: tell the user their sign-in is `admin@example.com` at https://<DOMAIN>/mixpost/login,
that the password is in /srv/mixpost/.env, read with
`sudo grep ADMIN_PASSWORD /srv/mixpost/.env`, and that it belongs in their password manager
now, because nothing here sends mail and there is no reset.
Do not continue until they confirm. Tell them not to change the email address on that
profile page: the container recreates `admin@example.com` with the published password
whenever it is missing.

## 8. First backup and restore

Two artifacts. The dump holds the accounts, posts, calendar and encrypted provider
credentials. The archive holds what rebuilds the service around it, and `.env` is its
load-bearing half: `APP_KEY` decrypts those credentials, so a dump restored without it is a
table of unreadable tokens.

```bash
cd /srv/mixpost
docker compose exec -T mysql sh -c 'exec mysqldump -u mixpost -p"$MYSQL_PASSWORD" --single-transaction --no-tablespaces mixpost' | gzip > /srv/mixpost/backups/mixpost-db-$(date +%F).sql.gz
sudo tar -czf /srv/mixpost/backups/mixpost-files-$(date +%F).tar.gz -C /srv/mixpost compose.yml .env trustedproxy.php storage logs -C /etc/caddy Caddyfile
ls -lh /srv/mixpost/backups/
```

Assert: both exist and are non-empty. Print both sizes. Nothing stops, because
`--single-transaction` snapshots InnoDB consistently; `--no-tablespaces` is there because
the app user has no PROCESS privilege and MySQL 8 refuses the dump without it. A backup on
the same disk is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/mixpost
scp vps:/srv/mixpost/backups/* ~/backups/mixpost/
```

To restore: `docker compose down`, `sudo rm -rf /srv/mixpost/mysql`, recreate it as in step
2, untar the archive into /srv/mixpost so `.env` is back before anything starts,
`docker compose up -d mysql`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T mysql sh -c 'exec mysql -u mixpost -p"$MYSQL_PASSWORD" mixpost'`,
then `docker compose up -d`. A MySQL volume keeps the password it was created with, so
restore that `.env`.

## 9. Updating later

Releases are listed at https://github.com/inovector/mixpost/releases and the image tags at
https://hub.docker.com/r/inovector/mixpost/tags. Read both: the repository is the Laravel
package, the image is that package inside an application skeleton, so a version lands there
only once upstream builds it. 2.6.0 of 2026-03-16 is the newest of either as of 2026-08-14,
and the date of the last commit on main. Back up both artifacts, then edit the image line in
/srv/mixpost/compose.yml to the new tag and digest:

```bash
cd /srv/mixpost
docker compose pull
docker compose up -d
docker compose logs --tail 30 mixpost
```

The container runs `php artisan migrate --force` on the way up, so watch that log until it
settles, then re-run step 7's four checks. Sessions live inside the container, so an update
signs everyone out. MySQL stays on 8.4 LTS on purpose: the innovation releases change
behaviour quarterly, the wrong thing under a scheduler.

## 10. What will probably go wrong

The minutes between `docker compose up -d` and the rotation later in step 7 are the
dangerous part of this install, and they are easy to walk past. For that window the box
answers on a public hostname with a username and password printed in upstream's install
guide, and a hostname minutes old is not private: it enters a certificate transparency log
the moment Caddy asks for it, and people read those logs for a living. Run step 7 straight
through. Then leave the account's email address alone: I read `start.sh` in the image, and
it recreates `admin@example.com` with that password on any start where the row is gone.

## 11. Out of scope

- Do not connect a social account, and do not create developer apps for the user. Mixpost
  Lite publishes to Facebook Pages, X and Mastodon; the first two need an app registered in
  that company's own developer portal, with a callback under
  `https://<DOMAIN>/mixpost/callback/`, and its id and secret entered under Settings,
  Services. Some of those registrations are reviewed by a person and take days.
- Do not change the account's email address, and do not add a second user. There is one
  account, `admin@example.com`; step 10 says why.
- Do not configure SMTP or set any `MAIL_` variable. Nothing here sends mail.
- Do not set `MIXPOST_DISK` to s3 or add any `AWS_` variable. The media library is a
  directory on this box and step 8 backs it up.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Mixpost Lite 2.6.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record
already points at the box.

Read this before step 1. Two facts shape the whole install. `<DOMAIN>` becomes `APP_URL` and
the host inside every OAuth callback URL you register at X and at Meta, so changing it later
means editing each of those developer apps by hand. And the container ships a standing
default account, `admin@example.com` with a password printed in upstream's own install
guide, recreated on any start where that row is missing. Step 7 replaces the password and
proves the published one no longer works; do not stop before it.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a
minute, and run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a
hostname that does not resolve. On memory, 2048 MB is the floor for three containers with
MySQL among them; upstream's troubleshooting page asks for 4 GB, aimed mostly at its
Enterprise edition, and video is the spike, because the image bundles ffmpeg and converts
uploads itself. All three images publish amd64 and arm64.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/mixpost /srv/mixpost/backups
sudo install -d -m 750 /srv/mixpost/storage /srv/mixpost/logs
sudo install -d -m 700 /srv/mixpost/mysql /srv/mixpost/redis
ls -la /srv/mixpost
```

You should see: five directories. `backups` owned by you, `storage` and `logs` owned by
root at `drwxr-x---`, and `mysql` and `redis` at `drwx------` owned by root.

If you do not: leave the last four owned by root on purpose. MySQL and Redis chown their own
data directory the first time they start, and one you have already chowned to yourself makes
MySQL refuse to initialise. `storage` and `logs` are chowned to www-data by the Mixpost
container itself at every start, because that container runs a `chown -R` over its whole
application directory before anything else.

## 3. Secrets

Four secrets, all generated here on the server, straight into a file only you can read: the
Laravel application key, the database password, the MySQL root password, and the password
step 7 will put on the account the container creates.

```bash
umask 077
cat > /srv/mixpost/.env <<EOF
MIXPOST_DOMAIN=<DOMAIN>
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/mixpost/.env
umask 022
ls -l /srv/mixpost/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>`
on the first line with your real hostname before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens
if you pasted the lines separately in different shells. Run `chmod 600 /srv/mixpost/.env`
and carry on. If the file already existed from an earlier attempt, this block has now
overwritten all four values, which is fine before MySQL exists and a problem afterwards: a
MySQL volume keeps the password it was created with, so a changed password against an
existing volume shows up as an authentication failure in the Mixpost log rather than as
anything about passwords.

Do not paste that file, any of those four values, or any command output containing them into
this chat window. This is the one rule the agent path never has to think about and this path
does: the values are on your server, and a chat window is somebody else's computer.

`APP_KEY` is the one to understand. It is a Laravel key for AES-256-CBC, 32 random bytes in
base64, and every provider secret and social token Mixpost stores is encrypted with it.
Upstream offers a web page that generates one; this generates its own, because a key fetched
from somebody else's website is a key somebody else saw. Compose reads this file from the
working directory, so run every command from here on with /srv/mixpost as your working
directory.

## 4. compose.yml and the trusted-proxy file

Two files. Paste the config file first, whole, including the last two lines. It has to exist
before the first `docker compose up`, because a bind mount whose host path is missing makes
Docker create a directory at that path instead.

```bash
cat > /srv/mixpost/trustedproxy.php <<'EOF'
<?php
// Mixpost Lite · trusted proxies. Authored by caniselfhostit from the image's
// vendor/laravel/framework/src/Illuminate/Http/Middleware/TrustProxies.php,
// which trusts nothing unless the application sets a proxy list, which this
// image does not, or config('trustedproxy.proxies') exists, which is this
// file. Without it Laravel reads Caddy's plain http connection and hands the
// browser http:// URLs on an https page, the Ziggy route table the dashboard
// drives itself from included, and browsers block those.
//
// '*' trusts the address that connected, which behind a loopback-only
// published port is only ever the Caddy on this host.

return [
    'proxies' => '*',
];
EOF
chmod 644 /srv/mixpost/trustedproxy.php
```

You should see: no output at all, and `ls -l /srv/mixpost/trustedproxy.php` showing a file
of a few hundred bytes.

If you do not: `bash: !: event not found` means your shell tried to expand something inside
the heredoc. Paste the block again in one go; the `<<'EOF'` quoting is what stops that, and
it only works when the whole block arrives together.

Now the three services. Paste the whole block at once, including the last two lines.

```bash
cat > /srv/mixpost/compose.yml <<'EOF'
# Mixpost Lite · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   docker install ...... https://docs.mixpost.app/lite/installation/docker
#   variable reference .. https://docs.mixpost.app/lite/configuration/environment-variables
#   troubleshooting ..... https://docs.mixpost.app/troubleshooting
#
# Three services. The Mixpost image is one Ubuntu container running nginx,
# PHP-FPM, the Horizon queue worker and cron under supervisord; MySQL holds the
# posts, accounts and encrypted provider credentials; Redis is what Horizon
# queues on. Upstream's compose file names mysql/mysql-server, a Docker Hub
# repository whose newest tag is 8.0.32 from January 2023, so this pins the
# official mysql image on the 8.4 LTS line, and every image carries a tag and
# a digest. Digests read 2026-08-14, all amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: mixpost

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      # The image will not initialise without a root variable; nothing here
      # connects as root.
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: mixpost
      MYSQL_USER: mixpost
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/mixpost/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", 'mysqladmin ping -h 127.0.0.1 -u "$$MYSQL_USER" -p"$$MYSQL_PASSWORD" --silent']
      interval: 10s
      retries: 18
    # No `ports:` at all: 3306 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/mixpost/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  mixpost:
    image: inovector/mixpost:v2.6.0@sha256:90cf94cec73dcaf87989d30b0de7a84b0625ff06797ba61c8ecb54e8fe1e10c4
    restart: unless-stopped
    environment:
      APP_NAME: Mixpost
      # Every stored provider secret and social token is encrypted with this.
      APP_KEY: ${APP_KEY}
      APP_DEBUG: "false"
      APP_URL: "https://${MIXPOST_DOMAIN}"
      DB_HOST: mysql
      DB_PORT: "3306"
      DB_DATABASE: mixpost
      DB_USERNAME: mixpost
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: "6379"
      MIXPOST_DISK: public
    volumes:
      # The media library, on the "public" disk, then the application log.
      - /srv/mixpost/storage:/var/www/html/storage/app
      - /srv/mixpost/logs:/var/www/html/storage/logs
      # Why this third file exists is written inside it. It has to exist on the
      # host first, or Docker creates a directory at that path.
      - /srv/mixpost/trustedproxy.php:/var/www/html/config/trustedproxy.php
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8197.
      - "127.0.0.1:8197:80"
    # If Facebook connections later time out, upstream's troubleshooting page
    # says Meta refuses the container over IPv6: add a `sysctls:` entry here,
    # net.ipv6.conf.all.disable_ipv6=1.
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
EOF
cd /srv/mixpost && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `variable is not set` means step 3 did not write .env into /srv/mixpost, or
you are in a different directory. `services must be a mapping` means the indentation was
lost between the page and your terminal: run `rm /srv/mixpost/compose.yml` and paste again
in one go. Upstream's own compose file names `mysql/mysql-server`, a Docker Hub repository
whose newest tag is 8.0.32 from January 2023, and a floating tag for every image. This one
pins the official mysql image on the 8.4 LTS line and gives every image a digest.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-mixpost
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Mixpost Lite · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.mixpost.app/lite/installation/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. It is also
# MIXPOST_DOMAIN in .env and the host inside every OAuth callback URL you
# register at X and at Meta.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting an account bounces out to a provider.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8197 is the loopback port compose publishes here, not a container port,
	# and not open in the firewall. Caddy sets X-Forwarded-Proto on the way
	# through, and trustedproxy.php is what tells the app to believe it.
	reverse_proxy 127.0.0.1:8197
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-mixpost /etc/caddy/Caddyfile`,
reload, and paste again. Caddy issues the certificate on the first request and renews it on
its own. It also terminates TLS and speaks plain http to the container, which is the whole
reason step 4 wrote trustedproxy.php: without that file the application believes the request
arrived over http and hands your browser http:// links on an https page.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8197`, `3306` or `6379`.

If you do not: delete anything for those three with, for example,
`sudo ufw delete allow 8197`. 8197 is bound to 127.0.0.1 by the compose file, and MySQL and
Redis publish no host port at all, so there is nothing for a firewall rule to apply to.
`Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

The first start is the slow one. MySQL initialises its data directory, then the Mixpost
container waits for it and runs every Laravel migration, so the loop below is allowed seven
minutes.

```bash
cd /srv/mixpost
docker compose pull
docker compose up -d
for i in $(seq 1 42); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8197/mixpost/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/mixpost/login | grep -c 'Log in'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/horizon
curl -sS -o /dev/null -w '%{redirect_url}\n' https://<DOMAIN>/mixpost
```

You should see, in order: the loop climbing through `502` and ending on `200`, then `1`,
then `403`, then `https://<DOMAIN>/mixpost/login`.

If you do not: a loop that never leaves `502` after seven minutes is usually MySQL. Run
`docker compose logs --tail 20 mysql` first, then `docker compose logs --tail 40 mixpost`.
A curl error rather than a number on the three https lines is a different problem: that is
Caddy still issuing a certificate for an A record you created minutes ago, and it clears on
its own within a minute or two.
The `403` is Laravel Horizon, the queue dashboard this image mounts at /horizon: it refuses
anyone who is not signed in, and a `200` there would mean your queue dashboard is public.
The last line is the one worth reading twice. It must start `https`. An `http://` target
means trustedproxy.php did not load, and the dashboard will break in a browser even though
curl looks content, because the page will try to call itself over http from an https origin
and the browser will block it.

Now close the door the image leaves open. `start.sh` inside the container runs
`mixpost-auth:create --admin` on every start where no row with the address
`admin@example.com` exists, and that command sets the password upstream prints in its own
install guide. The next block signs in with it once over loopback, replaces it with the one
step 3 generated, and then proves the published one is dead.

```bash
cd /srv/mixpost
umask 077
B=http://127.0.0.1:8197
tok() { sed -n 's/.*name="csrf-token" content="\([^"]*\)".*/\1/p'; }
printf '%s' "$(grep '^ADMIN_PASSWORD' .env | cut -d= -f2-)" > .newpw
rm -f .jar
T=$(curl -sS -c .jar $B/mixpost/login | tok)
curl -sS -b .jar -c .jar -o /dev/null -w 'seeded %{redirect_url}\n' -X POST $B/mixpost/login --data-urlencode "_token=$T" --data-urlencode 'email=admin@example.com' --data-urlencode 'password=changeme'
T=$(curl -sS -b .jar -c .jar $B/mixpost/profile | tok)
curl -sS -b .jar -c .jar -o /dev/null -w 'rotate %{http_code}\n' -X PUT $B/mixpost/profile/password --data-urlencode "_token=$T" --data-urlencode 'current_password=changeme' --data-urlencode 'password@.newpw' --data-urlencode 'password_confirmation@.newpw'
rm -f .jar
T=$(curl -sS -c .jar $B/mixpost/login | tok)
curl -sS -b .jar -c .jar -o /dev/null -w 'published %{redirect_url}\n' -X POST $B/mixpost/login --data-urlencode "_token=$T" --data-urlencode 'email=admin@example.com' --data-urlencode 'password=changeme'
rm -f .jar .newpw
umask 022
```

You should see three lines: `seeded http://127.0.0.1:8197/mixpost`, then `rotate 302`, then
`published http://127.0.0.1:8197/mixpost/login`.

If you do not: the first line is the control. It says the published password worked, which
is the door you are closing; if it already ends in `/mixpost/login`, either the container
has not finished starting or somebody has been here before you, and you should stop and read
`docker compose logs --tail 40 mixpost`. The third line is the closure: the same credential
now bounces back to the sign-in page. If the third still ends in `/mixpost`, the rotation
did not take. Do not carry on. Open https://<DOMAIN>/mixpost/login in a browser, sign in as
`admin@example.com` with the published password, and change it under Profile before you do
anything else with this box.

Now sign in yourself. Read your password with `sudo grep ADMIN_PASSWORD /srv/mixpost/.env`,
put it in your password manager, and open https://<DOMAIN>/mixpost/login. The first screen
shows `Email` and `Password` fields under the Mixpost logo, with a `Log in` button. There is
no reset link and no mail configured, so that password manager entry is the only copy you
have.

One thing not to change: the account's email address. The container recreates
`admin@example.com` with upstream's published password on any start where that address is
missing from the users table, so renaming the account quietly reopens the door at the next
restart. Change the display name and the password as often as you like; leave the address
alone.

## 8. First backup and restore

Two artifacts. The dump holds the accounts, the posts, the calendar and the encrypted
provider credentials. The archive holds the files that rebuild the service around it, media
library and `.env` included. `.env` is the load-bearing half: `APP_KEY` is what those
credentials are encrypted with, so a dump restored without it is a table of unreadable
tokens.

```bash
cd /srv/mixpost
docker compose exec -T mysql sh -c 'exec mysqldump -u mixpost -p"$MYSQL_PASSWORD" --single-transaction --no-tablespaces mixpost' | gzip > /srv/mixpost/backups/mixpost-db-$(date +%F).sql.gz
sudo tar -czf /srv/mixpost/backups/mixpost-files-$(date +%F).tar.gz -C /srv/mixpost compose.yml .env trustedproxy.php storage logs -C /etc/caddy Caddyfile
ls -lh /srv/mixpost/backups/
```

You should see: two files, both non-empty. Nothing goes offline, because
`--single-transaction` snapshots InnoDB consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mysqldump`
failed and the shell created the file anyway. Run the dump line without `| gzip` to read the
error. `Access denied; you need the PROCESS privilege` means `--no-tablespaces` was dropped:
the application user does not have that privilege and MySQL 8 refuses the dump without the
flag.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:

```bash
mkdir -p ~/backups/mixpost
scp vps:/srv/mixpost/backups/* ~/backups/mixpost/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/mixpost/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the `vps` alias Prompt Zero created
lives.

Now prove the restore, today, while the only thing at risk is one empty account:

```bash
cd /srv/mixpost
docker compose down
sudo rm -rf /srv/mixpost/mysql
sudo install -d -m 700 /srv/mixpost/mysql
docker compose up -d mysql
sleep 60
gunzip -c /srv/mixpost/backups/mixpost-db-$(date +%F).sql.gz | docker compose exec -T mysql sh -c 'exec mysql -u mixpost -p"$MYSQL_PASSWORD" mixpost'
docker compose up -d
sleep 90
curl -sS -o /dev/null -w '%{redirect_url}\n' https://<DOMAIN>/mixpost
```

You should see: no output from the `gunzip` pipe, then `https://<DOMAIN>/mixpost/login` from
the last command, and your rotated password still works in the browser.

If you do not: `Access denied for user 'mixpost'` means the container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what this protects:
the posts, the calendar and the connected accounts come back from that dump, and a connected
account comes back working only if the network's token has not expired meanwhile.

## 9. Updating later

Releases are listed at https://github.com/inovector/mixpost/releases and the matching image
tags at https://hub.docker.com/r/inovector/mixpost/tags. Read both: that repository is the
Laravel package, and the image is that package installed into an application skeleton, so a
version exists on Docker Hub only once upstream has built it. 2.6.0, published 2026-03-16,
is the newest of either as of 2026-08-14, and that is also the date of the last commit on
the repository's main branch. Take both backup artifacts first, then edit the `image:` line
in /srv/mixpost/compose.yml to the new tag and its digest.

```bash
cd /srv/mixpost
docker compose pull
docker compose up -d
docker compose logs --tail 30 mixpost
```

You should see: migration output, then the Mixpost banner, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run
the four checks from step 7 before you call the update done. Two things to expect on any
update: sessions live inside the container and are not mounted, so everyone signed in gets
signed out, and MySQL stays on the 8.4 LTS line on purpose, because the innovation releases
change behaviour every quarter and a database under a scheduler is the wrong place for that.

## 10. What will probably go wrong

The minutes between `docker compose up -d` and the rotation later in step 7 are the
dangerous part of this install, and they are easy to walk past. For that window your box is
answering on a public hostname with a username and password printed in upstream's install
guide, and a hostname minutes old is not private: it enters a certificate transparency log
the moment Caddy asks for the certificate, and people read those logs for a living. Run step
7 straight through, in one sitting, before you go and make coffee. Then leave the account's
email address alone, however tempting a rename is: I read `start.sh` inside the image, and
it recreates `admin@example.com` with that same published password on any start where the
row is gone.

## 11. Out of scope

- Do not connect a social account yet. Mixpost Lite publishes to Facebook Pages, X and
  Mastodon. The first two need an app registered in that company's own developer portal,
  with a callback under `https://<DOMAIN>/mixpost/callback/`, and its id and secret entered
  under Settings, Services inside Mixpost. Some of those registrations are reviewed by a
  person at the other company and take days. Mastodon is the exception: Mixpost registers
  that application itself against the instance you name.
- Do not change the account's email address, and do not add a second user. There is one
  account, `admin@example.com`, and step 10 says why that matters.
- Do not configure SMTP or set any `MAIL_` variable. Nothing in this install sends mail, and
  the account password lives in .env instead of behind a reset link.
- Do not set `MIXPOST_DISK` to s3 or add any `AWS_` variable. The media library is a
  directory on this box and step 8 backs it up.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Mixpost Lite 2.6.0, with the MySQL and Redis it needs, under ~/selfhost/mixpost,
answering at http://localhost:8197.

## 1. Preflight

Say this before step 2; it decides whether the user wants this install at all. Everything
answers at http://localhost:8197, so a post scheduled for 9am goes out only if the machine
is awake at 9am, and the callback URL Mixpost hands a developer portal is a localhost
address that X and Meta refuse. Mastodon needs no app in a company's portal, so it is the
provider this path can connect.

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux
the ID and codename print next, for step 2. This stack needs 2048 MB of RAM available and
10 GB free on the home disk, and all three images are amd64 and arm64. If either is under
that, print both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/mixpost/backups ~/selfhost/mixpost/storage ~/selfhost/mixpost/logs
ls -la ~/selfhost/mixpost
```

Assert: three directories, all owned by the user. MySQL and Redis keep their data in volumes
Docker manages, so nothing here needs an ownership fix. On Linux the container chowns
`storage` and `logs` to www-data at first start; mode 755 keeps them readable to you, which
step 8 relies on. On macOS and Windows, Docker Desktop owns that problem.

## 4. Secrets

Four, all generated here. Print none of them, and keep them out of your summary and any log
line.

```bash
umask 077
cat > ~/selfhost/mixpost/.env <<EOF
APP_KEY=base64:$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/mixpost/.env
umask 022
ls -l ~/selfhost/mixpost/.env
```

Assert: mode `-rw-------`. `APP_KEY` is a Laravel key for AES-256-CBC, 32 random bytes in
base64, and it encrypts every stored provider secret and social token. `ADMIN_PASSWORD`
reaches no container: step 7 sets it on the account the image creates. Compose reads this
file from the working directory, so run everything from ~/selfhost/mixpost. On Windows the
mode bits are advisory and the boundary is the user's account.

## 5. compose.yml

```bash
cat > ~/selfhost/mixpost/compose.yml <<'EOF'
# Mixpost Lite · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ...... https://docs.mixpost.app/lite/installation/docker
#   variable reference .. https://docs.mixpost.app/lite/configuration/environment-variables
#   troubleshooting ..... https://docs.mixpost.app/troubleshooting
#
# Three services, every path relative to ~/selfhost/mixpost/ so one file works
# on macOS, Linux and Windows. MySQL and Redis get named volumes, because they
# chown their data directory to a uid of their own and Docker Desktop cannot
# grant that on a Windows bind mount; the media library and the log stay
# relative binds. Nothing terminates TLS here, so no trusted-proxy file is
# needed. mysql is pinned on the 8.4 LTS line, not the mysql/mysql-server
# repository upstream names, whose newest tag is 8.0.32 from January 2023.
# Digests read 2026-08-14, all amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: mixpost

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      # The image will not initialise without a root variable; nothing
      # connects as root here.
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: mixpost
      MYSQL_USER: mixpost
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mixpost-mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", 'mysqladmin ping -h 127.0.0.1 -u "$$MYSQL_USER" -p"$$MYSQL_PASSWORD" --silent']
      interval: 10s
      retries: 18
    # No `ports:` at all: 3306 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - mixpost-redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  mixpost:
    image: inovector/mixpost:v2.6.0@sha256:90cf94cec73dcaf87989d30b0de7a84b0625ff06797ba61c8ecb54e8fe1e10c4
    restart: unless-stopped
    environment:
      APP_NAME: Mixpost
      # Every stored provider secret and social token is encrypted with this.
      APP_KEY: ${APP_KEY}
      APP_DEBUG: "false"
      APP_URL: "http://localhost:8197"
      DB_HOST: mysql
      DB_PORT: "3306"
      DB_DATABASE: mixpost
      DB_USERNAME: mixpost
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: "6379"
      MIXPOST_DISK: public
    volumes:
      - ./storage:/var/www/html/storage/app
      - ./logs:/var/www/html/storage/logs
    ports:
      # Loopback only: no other device on the wifi can reach 8197.
      - "127.0.0.1:8197:80"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  mixpost-mysql:
  mixpost-redis:
EOF
cd ~/selfhost/mixpost && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK` and nothing else. An unset-variable warning means step 4 did not write
.env into ~/selfhost/mixpost.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. There is no hostname to resolve and a
certificate attests a public name nothing here has; browsers treat http://localhost as
secure anyway.

```bash
grep -c '"127.0.0.1:' ~/selfhost/mixpost/compose.yml
```

Assert: `1`. That line is `- "127.0.0.1:8197:80"`: not the user's phone, not a laptop on the
same wifi, not anyone on the internet. MySQL and Redis publish no host port. Outbound calls
still work: a loopback binding governs what arrives, not what the container can call.

## 7. Start and verify

MySQL initialises its data directory, then Mixpost waits for it and runs every migration, so
the first start is the slow one.

```bash
cd ~/selfhost/mixpost
docker compose pull
docker compose up -d
for i in $(seq 1 42); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8197/mixpost/login); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8197/mixpost/login | grep -c 'Log in'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8197/horizon
curl -sS -o /dev/null -w '%{redirect_url}\n' http://localhost:8197/mixpost
```

Assert all four, printing what you received. The loop ends on `200`. The grep prints `1`,
the sign-in button. `/horizon` prints `403`: the queue dashboard this image mounts refuses
anyone not signed in. The last prints `http://localhost:8197/mixpost/login`. On any miss,
stop and run `docker compose logs --tail 40 mixpost`; a MySQL container stuck below healthy
points at step 4, and `port is already allocated` means something else holds 8197
(`lsof -nP -iTCP:8197 -sTCP:LISTEN`, or `netstat -ano | findstr :8197` on Windows).

Now close the door the image leaves open. `start.sh` in the container creates
`admin@example.com` whenever that row is missing, with the password upstream prints in its
install guide. Sign in with it once, replace it, prove the published one is dead:

```bash
cd ~/selfhost/mixpost
umask 077
B=http://localhost:8197
tok() { sed -n 's/.*name="csrf-token" content="\([^"]*\)".*/\1/p'; }
printf '%s' "$(grep '^ADMIN_PASSWORD' .env | cut -d= -f2-)" > .newpw
rm -f .jar
T=$(curl -sS -c .jar $B/mixpost/login | tok)
curl -sS -b .jar -c .jar -o /dev/null -w 'seeded %{redirect_url}\n' -X POST $B/mixpost/login --data-urlencode "_token=$T" --data-urlencode 'email=admin@example.com' --data-urlencode 'password=changeme'
T=$(curl -sS -b .jar -c .jar $B/mixpost/profile | tok)
curl -sS -b .jar -c .jar -o /dev/null -w 'rotate %{http_code}\n' -X PUT $B/mixpost/profile/password --data-urlencode "_token=$T" --data-urlencode 'current_password=changeme' --data-urlencode 'password@.newpw' --data-urlencode 'password_confirmation@.newpw'
rm -f .jar
T=$(curl -sS -c .jar $B/mixpost/login | tok)
curl -sS -b .jar -c .jar -o /dev/null -w 'published %{redirect_url}\n' -X POST $B/mixpost/login --data-urlencode "_token=$T" --data-urlencode 'email=admin@example.com' --data-urlencode 'password=changeme'
rm -f .jar .newpw
umask 022
```

Assert three lines. `seeded http://localhost:8197/mixpost` is the published password
working, the door being closed. `rotate 302`. `published
http://localhost:8197/mixpost/login` is that credential bounced back, the closure. If the
third still ends in `/mixpost`, stop and have the user change it in the browser at once.

STOP: tell the user to open http://localhost:8197/mixpost/login and sign in as
`admin@example.com`, with the password from `grep ADMIN_PASSWORD ~/selfhost/mixpost/.env`.
Do not continue until they confirm they see the dashboard. Tell them not to change that
email address: the container recreates it with the published password whenever it is gone.

## 8. First backup and restore

Two artifacts. The dump holds the accounts, posts, calendar and encrypted provider
credentials; the archive holds what rebuilds the service around it, and `APP_KEY` in `.env`
is what decrypts them.

```bash
cd ~/selfhost/mixpost
docker compose exec -T mysql sh -c 'exec mysqldump -u mixpost -p"$MYSQL_PASSWORD" --single-transaction --no-tablespaces mixpost' | gzip > ~/selfhost/mixpost/backups/mixpost-db-$(date +%F).sql.gz
tar -C ~/selfhost/mixpost -czf ~/selfhost/mixpost/backups/mixpost-files-$(date +%F).tar.gz compose.yml .env storage logs
ls -lh ~/selfhost/mixpost/backups/
```

Assert: both exist and are non-empty. Print both sizes. Nothing stops:
`--single-transaction` snapshots InnoDB consistently, and `--no-tablespaces` is there
because the app user has no PROCESS privilege. Redis is in neither: it holds a queue this
install rebuilds.

Both sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination off this computer, a sync folder or a USB stick,
and copy both there with `cp`; in Git Bash a Windows drive is written `/d/Backups`. Assert:
the user confirms both are listed there.

To restore, in this order. `cd ~/selfhost/mixpost`, untar the archive there first so
compose.yml and .env are back before any container starts: MySQL takes its password from
.env the moment it initialises an empty volume. Then `docker compose down -v`,
`docker compose up -d mysql`, wait 60 seconds, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T mysql sh -c 'exec mysql -u mixpost -p"$MYSQL_PASSWORD" mixpost'`,
then `docker compose up -d`.

## 9. Updating later

Releases are listed at https://github.com/inovector/mixpost/releases and the image tags at
https://hub.docker.com/r/inovector/mixpost/tags. Read both: the repository is the Laravel
package, the image is that package inside an application skeleton. 2.6.0 of 2026-03-16 is
the newest of either as of 2026-08-14, and the date of the last commit on main. Back up,
then edit the image line in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/mixpost
docker compose pull
docker compose up -d
docker compose logs --tail 30 mixpost
```

The container migrates its own database on the way up, so watch that log until it settles,
then re-run step 7's four checks.

## 10. What will probably go wrong

I scheduled a post for the next morning, closed the lid, and found it still in the calendar
the following afternoon. Nothing was broken: a scheduled post is a queued job a cron inside
the container releases, and a sleeping machine runs no containers, so the time passed with
nobody to notice. Docker Desktop failing to start after a reboot looks identical. Run
`docker compose ps` before concluding anything, and treat this install as somewhere to draft
and stage, not something that publishes while you are away from the desk.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not connect a social account, and do not create developer apps for the user. Mixpost
  Lite publishes to Facebook Pages, X and Mastodon, and the callback here is a localhost
  address the first two refuse.
- Do not change the account's email address, and do not add a second user. Step 7 says why.
- Do not set `MIXPOST_DISK` to s3 or add any `AWS_` variable. The media library is a folder
  in the user's home directory and step 8 backs it up.
````

## docker-compose.yml

```yaml
# Mixpost Lite · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   docker install ...... https://docs.mixpost.app/lite/installation/docker
#   variable reference .. https://docs.mixpost.app/lite/configuration/environment-variables
#   troubleshooting ..... https://docs.mixpost.app/troubleshooting
#
# Three services. The Mixpost image is one Ubuntu container running nginx,
# PHP-FPM, the Horizon queue worker and cron under supervisord; MySQL holds the
# posts, accounts and encrypted provider credentials; Redis is what Horizon
# queues on. Upstream's compose file names mysql/mysql-server, a Docker Hub
# repository whose newest tag is 8.0.32 from January 2023, so this pins the
# official mysql image on the 8.4 LTS line, and every image carries a tag and
# a digest. Digests read 2026-08-14, all amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: mixpost

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      # The image will not initialise without a root variable; nothing here
      # connects as root.
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: mixpost
      MYSQL_USER: mixpost
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/mixpost/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", 'mysqladmin ping -h 127.0.0.1 -u "$$MYSQL_USER" -p"$$MYSQL_PASSWORD" --silent']
      interval: 10s
      retries: 18
    # No `ports:` at all: 3306 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - /srv/mixpost/redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  mixpost:
    image: inovector/mixpost:v2.6.0@sha256:90cf94cec73dcaf87989d30b0de7a84b0625ff06797ba61c8ecb54e8fe1e10c4
    restart: unless-stopped
    environment:
      APP_NAME: Mixpost
      # Every stored provider secret and social token is encrypted with this.
      APP_KEY: ${APP_KEY}
      APP_DEBUG: "false"
      APP_URL: "https://${MIXPOST_DOMAIN}"
      DB_HOST: mysql
      DB_PORT: "3306"
      DB_DATABASE: mixpost
      DB_USERNAME: mixpost
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: "6379"
      MIXPOST_DISK: public
    volumes:
      # The media library, on the "public" disk, then the application log.
      - /srv/mixpost/storage:/var/www/html/storage/app
      - /srv/mixpost/logs:/var/www/html/storage/logs
      # Why this third file exists is written inside it. It has to exist on the
      # host first, or Docker creates a directory at that path.
      - /srv/mixpost/trustedproxy.php:/var/www/html/config/trustedproxy.php
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8197.
      - "127.0.0.1:8197:80"
    # If Facebook connections later time out, upstream's troubleshooting page
    # says Meta refuses the container over IPv6: add a `sysctls:` entry here,
    # net.ipv6.conf.all.disable_ipv6=1.
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Mixpost Lite · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ...... https://docs.mixpost.app/lite/installation/docker
#   variable reference .. https://docs.mixpost.app/lite/configuration/environment-variables
#   troubleshooting ..... https://docs.mixpost.app/troubleshooting
#
# Three services, every path relative to ~/selfhost/mixpost/ so one file works
# on macOS, Linux and Windows. MySQL and Redis get named volumes, because they
# chown their data directory to a uid of their own and Docker Desktop cannot
# grant that on a Windows bind mount; the media library and the log stay
# relative binds. Nothing terminates TLS here, so no trusted-proxy file is
# needed. mysql is pinned on the 8.4 LTS line, not the mysql/mysql-server
# repository upstream names, whose newest tag is 8.0.32 from January 2023.
# Digests read 2026-08-14, all amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

name: mixpost

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    restart: unless-stopped
    environment:
      # The image will not initialise without a root variable; nothing
      # connects as root here.
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: mixpost
      MYSQL_USER: mixpost
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mixpost-mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", 'mysqladmin ping -h 127.0.0.1 -u "$$MYSQL_USER" -p"$$MYSQL_PASSWORD" --silent']
      interval: 10s
      retries: 18
    # No `ports:` at all: 3306 is reachable only from the other containers.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - mixpost-redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 12

  mixpost:
    image: inovector/mixpost:v2.6.0@sha256:90cf94cec73dcaf87989d30b0de7a84b0625ff06797ba61c8ecb54e8fe1e10c4
    restart: unless-stopped
    environment:
      APP_NAME: Mixpost
      # Every stored provider secret and social token is encrypted with this.
      APP_KEY: ${APP_KEY}
      APP_DEBUG: "false"
      APP_URL: "http://localhost:8197"
      DB_HOST: mysql
      DB_PORT: "3306"
      DB_DATABASE: mixpost
      DB_USERNAME: mixpost
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: "6379"
      MIXPOST_DISK: public
    volumes:
      - ./storage:/var/www/html/storage/app
      - ./logs:/var/www/html/storage/logs
    ports:
      # Loopback only: no other device on the wifi can reach 8197.
      - "127.0.0.1:8197:80"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  mixpost-mysql:
  mixpost-redis:
```

## Caddyfile

```text
# Mixpost Lite · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.mixpost.app/lite/installation/docker and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. It is also
# MIXPOST_DOMAIN in .env and the host inside every OAuth callback URL you
# register at X and at Meta.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		# Not no-referrer: connecting an account bounces out to a provider.
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8197 is the loopback port compose publishes here, not a container port,
	# and not open in the firewall. Caddy sets X-Forwarded-Proto on the way
	# through, and trustedproxy.php is what tells the app to believe it.
	reverse_proxy 127.0.0.1:8197
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Mixpost Lite · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=mixpost.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.mixpost.app/lite/installation/docker
#   https://docs.mixpost.app/lite/configuration/environment-variables
#   https://docs.mixpost.app/troubleshooting
#   https://hub.docker.com/_/mysql
#
# Four secrets are generated here, on this machine: the Laravel application key
# that encrypts every stored social token, the database password, the MySQL root
# password, and the password this script sets on the account the container
# creates. All four go into /srv/mixpost/.env with mode 600 and none is printed.
#
# The container seeds a user admin@example.com with the password upstream prints
# in its own install guide. This script signs in with that password once, over
# loopback, replaces it with the generated one, and then proves the published
# default no longer works. Do not change that account's email address afterwards:
# the container recreates admin@example.com, with the published password, on any
# start where that row is missing.
#
# DOMAIN_HOST is the host inside every OAuth redirect URI you will register at X
# and at Meta. Choose it once.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/mixpost}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
BASE="http://127.0.0.1:8197"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
csrf() { sed -n 's/.*name="csrf-token" content="\([^"]*\)".*/\1/p'; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. mixpost.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; three containers with MySQL among them want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 "$APP_DIR/storage" "$APP_DIR/logs"
sudo install -d -m 700 "$APP_DIR/mysql" "$APP_DIR/redis"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# The application config file that makes Laravel believe Caddy's
# X-Forwarded-Proto. It has to exist before the first start, or Docker creates a
# directory at that mount path instead of a file.
cat > "$APP_DIR/trustedproxy.php" <<'PHPFILE'
<?php
// Mixpost Lite · trusted proxies. Authored by caniselfhostit from the image's
// vendor/laravel/framework/src/Illuminate/Http/Middleware/TrustProxies.php,
// which trusts nothing unless the application sets a proxy list, which this
// image does not, or config('trustedproxy.proxies') exists, which is this
// file. Without it Laravel reads Caddy's plain http connection and hands the
// browser http:// URLs on an https page, the Ziggy route table the dashboard
// drives itself from included, and browsers block those.
//
// '*' trusts the address that connected, which behind a loopback-only
// published port is only ever the Caddy on this host.

return [
    'proxies' => '*',
];
PHPFILE
chmod 644 "$APP_DIR/trustedproxy.php"

# --- 3. Generate the four secrets, on the server -----------------------------
#
# Read them later with
#   sudo cat /srv/mixpost/.env
# APP_KEY is a Laravel key for AES-256-CBC, which is 32 random bytes in base64.
# Upstream offers a web page that generates one; this makes its own instead,
# because a key fetched from someone else's website is a key someone else saw.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		MIXPOST_DOMAIN=${DOMAIN_HOST}
		APP_KEY=base64:$(openssl rand -base64 32)
		DB_PASSWORD=$(openssl rand -hex 32)
		MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
		ADMIN_PASSWORD=$(openssl rand -hex 24)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-mixpost"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8197, 3306 and 6379 are not among them ----------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8197, 3306 and 6379 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first start is the slow one: MySQL initialises its data directory, then
# the Mixpost container waits for it and runs every Laravel migration.

docker compose pull
docker compose up -d

echo "==> waiting for ${BASE}/mixpost/login (up to seven minutes)"
code=""
for _ in $(seq 1 42); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "${BASE}/mixpost/login" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "$code" = "200" ] || die "the sign-in page answered ${code:-nothing}. Check: docker compose logs --tail 40 mixpost"

curl -sS "https://${DOMAIN_HOST}/mixpost/login" | grep -q 'Log in' \
	|| die "the sign-in page did not come through Caddy. Check: sudo caddy validate --config /etc/caddy/Caddyfile"

horizon="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/horizon")"
[ "$horizon" = "403" ] || die "/horizon answered ${horizon}, not 403. The queue dashboard is meant to refuse anonymous callers."

dash="$(curl -sS -o /dev/null -w '%{redirect_url}' "https://${DOMAIN_HOST}/mixpost")"
[ "$dash" = "https://${DOMAIN_HOST}/mixpost/login" ] \
	|| die "/mixpost redirected to '${dash}', not https://${DOMAIN_HOST}/mixpost/login. An http:// target means trustedproxy.php did not load."

# --- 7. Close the door the image leaves open ---------------------------------
#
# start.sh inside the image runs `mixpost-auth:create --admin` on every start
# where no row with the address admin@example.com exists, and that command sets
# the password upstream prints in its install guide. Sign in with it once, over
# loopback, swap it for the generated one, then prove the published one is dead.

umask 077
printf '%s' "$(grep '^ADMIN_PASSWORD' "$APP_DIR/.env" | cut -d= -f2-)" > "$APP_DIR/.newpw"
JAR="$APP_DIR/.jar"
rm -f "$JAR"

T1="$(curl -sS -c "$JAR" "${BASE}/mixpost/login" | csrf)"
[ -n "$T1" ] || die "no CSRF token on the sign-in page. Stop and read it: curl -sS ${BASE}/mixpost/login"

first="$(curl -sS -b "$JAR" -c "$JAR" -o /dev/null -w '%{redirect_url}' -X POST "${BASE}/mixpost/login" \
	--data-urlencode "_token=${T1}" --data-urlencode 'email=admin@example.com' --data-urlencode 'password=changeme')"
[ "$first" = "${BASE}/mixpost" ] \
	|| die "signing in as the seeded account sent us to '${first}'. Expected ${BASE}/mixpost. Stop and investigate before anything else."
echo "==> the seeded account signed in, which is exactly the door being closed now"

T2="$(curl -sS -b "$JAR" -c "$JAR" "${BASE}/mixpost/profile" | csrf)"
[ -n "$T2" ] || die "no CSRF token on the profile page; the session did not carry."

curl -sS -b "$JAR" -c "$JAR" -o /dev/null -X PUT "${BASE}/mixpost/profile/password" \
	--data-urlencode "_token=${T2}" --data-urlencode 'current_password=changeme' \
	--data-urlencode "password@${APP_DIR}/.newpw" --data-urlencode "password_confirmation@${APP_DIR}/.newpw"

rm -f "$JAR"
T3="$(curl -sS -c "$JAR" "${BASE}/mixpost/login" | csrf)"
after="$(curl -sS -b "$JAR" -c "$JAR" -o /dev/null -w '%{redirect_url}' -X POST "${BASE}/mixpost/login" \
	--data-urlencode "_token=${T3}" --data-urlencode 'email=admin@example.com' --data-urlencode 'password=changeme')"
rm -f "$JAR" "$APP_DIR/.newpw"
umask 022
[ "$after" = "${BASE}/mixpost/login" ] \
	|| die "the published default still signs in (redirect '${after}'). The rotation failed. Stop and do it by hand in the browser."
echo "==> the published default is refused and bounced back to the sign-in page"

# --- 8. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T mysql sh -c 'exec mysqldump -u mixpost -p"$MYSQL_PASSWORD" --single-transaction --no-tablespaces mixpost' \
	| gzip > "$APP_DIR/backups/mixpost-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/mixpost-files-${STAMP}.tar.gz" \
	-C "$APP_DIR" compose.yml .env trustedproxy.php storage logs -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/mixpost-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	Mixpost Lite is answering at https://${DOMAIN_HOST}/mixpost/login

	  1. Sign in as admin@example.com. The password was generated here and is
	     in $APP_DIR/.env, mode 600. Read it with
	       sudo grep ADMIN_PASSWORD $APP_DIR/.env
	     It was not printed above. Put it in your password manager now: this
	     install has no mail configured, so there is no password reset.
	  2. Do not change that account's email address. The container recreates
	     admin@example.com with upstream's published password on any start
	     where that address is missing from the users table. Change the display
	     name and the password as much as you like; leave the address alone.
	  3. No social account is connected, and this script cannot connect one.
	     Mixpost Lite publishes to Facebook Pages, X and Mastodon, and the
	     first two need an app you register in that company's own developer
	     portal, with a callback of
	       https://${DOMAIN_HOST}/mixpost/callback/facebook
	       https://${DOMAIN_HOST}/mixpost/callback/twitter
	     and its id and secret entered under Settings, Services. Mastodon is
	     the exception: Mixpost registers that application itself against the
	     instance you name.
	  4. First backup written to $APP_DIR/backups: a database dump and a file
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight.

DONE
```

## Also evaluated

Ranked below Mixpost for this swap. The prompts above install Mixpost only.

- **Postiz** — One calendar for every social account, on your server, with the network API keys registered in your own name. The broader network list and the busier project, and the heavier install. Postiz is AGPL-3.0 and covers far more of Hootsuite's surface than Mixpost Lite does, but the stack is five services since it made Temporal a requirement, which puts it in ongoing-ops territory and on a 4 GB box at minimum. It keeps the top spot on the Buffer page, where the comparison is with a channel-metered product rather than a per-seat one. Here it ranks second on the self-hosting story alone: Mixpost needs three containers and no workflow engine, and on a Hootsuite budget the thing you are buying back is your evenings. Choose Postiz if the network list is what decides it, and be honest that you are choosing more machinery.

The page this mirrors: https://caniselfhostit.com/self-host/hootsuite/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
