Can I self-host Hootsuite?
YES, BUT · ONE WEEKEND— setup effort 3 of 4YES, BUT — it's called Mixpost. It takes one prompt, a 2048 MB VPS, and about 240 minutes. That is $199 a month you stop paying Hootsuite — $2,388 a year on the Professional plan, 1 seat assumed.
Why people pay for Hootsuite
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Hootsuite is a standing relationship with a dozen social platforms, sold by the seat. One login publishes to Instagram, LinkedIn, TikTok and the rest, and when one of those companies deprecates an endpoint, changes its terms or suspends an app, Hootsuite is the party filing the paperwork. The calendar and the shared inbox are what you see; what you are renting is somebody else keeping API access alive on your behalf, and a support queue to shout into when it stops.
| Plan | List price | What it buys |
|---|---|---|
| Standard | $99/mo per seat | $99 per user per month, billed annually, for up to 10 connected social accounts. Month-to-month billing costs more and the page does not print that rate. |
| Professionalthe plan this page prices against | $199/mo per seat | $199 per user per month, billed annually. Lifts the account cap to unlimited and adds inbox automation and custom reports. |
| Advanced | $399/mo per seat | $399 per user per month, billed annually. Adds approval workflows, message routing and team performance reporting. |
| Enterprise | quote only | Quote only. Adds advanced analytics, social listening through Talkwalker, single sign-on and compliance controls. |
Vendor list prices in USD, read from the pricing page on 2026-08-14 · confidence: medium
Replaced by Mixpost
One project, named before the prompt, so you know what you are about to install.
A posting calendar for Facebook Pages, X and Mastodon on your own box, with the developer apps registered in your name and no per-seat bill.
The calmest self-hosted answer here, and the narrowest. Three containers, MIT at the pinned tag, and no external service in the core loop: a calendar, per-network post versions, a media library and basic analytics, running on a box you already pay for. The catch is stated on the vendor's own pricing page rather than hidden: the free Lite edition publishes to Facebook Pages, X and Mastodon only, and Instagram, LinkedIn, TikTok, YouTube and the rest are the paid Pro and Enterprise editions at $299 and $1,199 one-time. Two more things to know before you start. The free repository is quiet, with release 2.6.0 dated March 2026 and no commit on main since. And the container seeds a published default password on first boot, which our install rotates within seconds and proves refused. If your posting is Facebook Pages, X and Mastodon, this replaces a Professional seat outright. If it is Instagram-first, it does not, at any price you can reach for free.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Mixpostour pickONE WEEKEND— setup effort 3 of 4
A posting calendar for Facebook Pages, X and Mastodon on your own box, with the developer apps registered in your name and no per-seat bill.
The calmest self-hosted answer here, and the narrowest. Three containers, MIT at the pinned tag, and no external service in the core loop: a calendar, per-network post versions, a media library and basic analytics, running on a box you already pay for. The catch is stated on the vendor's own pricing page rather than hidden: the free Lite edition publishes to Facebook Pages, X and Mastodon only, and Instagram, LinkedIn, TikTok, YouTube and the rest are the paid Pro and Enterprise editions at $299 and $1,199 one-time. Two more things to know before you start. The free repository is quiet, with release 2.6.0 dated March 2026 and no commit on main since. And the container seeds a published default password on first boot, which our install rotates within seconds and proves refused. If your posting is Facebook Pages, X and Mastodon, this replaces a Professional seat outright. If it is Instagram-first, it does not, at any price you can reach for free.
- PostizONGOING OPS— setup effort 4 of 4
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 swap
You'd run
Mixpost
ONE WEEKEND · ~240 min to running · 2048 MB RAM
Hootsuite Professional · 1 seat assumed · vendor list price · checked 2026-08-14 · source · confidence: medium
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~240 min3–24 hours, through the first backup
The prompt
Two paths to the same Mixpost: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
389 lines · 17,132 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml and the trusted-proxy file
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
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.348 lines · 16,043 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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.compose.local.ymlthe services, pinned · local layout81 lines
# 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:agent-readable mirror: /self-host/hootsuite.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned83 lines
# 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_healthyCaddyfilethe hostname and TLS27 lines
# 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.shthe same install, no agent231 lines
#!/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.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Hootsuite.
- Lite is the free edge of a commercial product, and the gate is the networks. Upstream's own pricing page lists Mixpost Lite as Facebook Pages, X and Mastodon; Instagram, LinkedIn, TikTok, YouTube, Pinterest, Threads, Bluesky and Google Business Profile are Pro and Enterprise, one-time licences at $299 and $1,199. Workspaces, approval flows, the posting queue, post templates, hashtag groups, the API, the MCP server and webhooks are paid too. The GitHub README describes Mixpost the product rather than this edition, so read the pricing page first.
- The free repository moves slowly. Release 2.6.0 is dated 2026-03-16, which is also the last commit on its main branch, five months quiet as of 2026-08-14. Nothing is archived, nothing has been relicensed, and the MIT licence at that tag is real. One footnote: the image builds that MIT package into inovector/MixpostApp, a Laravel skeleton whose repository carries no licence file of its own. This is a slow-moving free tier rather than an abandoned project, which is a different problem, though not a smaller one on the day a network changes an endpoint.
- You become the developer at X and at Meta. Connecting a Facebook Page or an X profile means registering an app in that company's developer portal, accepting its terms, living inside its rate limits, and pasting the id and secret into Mixpost under Settings, Services. Upstream's own FAQ says networks may impose rate limits and that raising one is a conversation with them, occasionally a paid one. Mastodon is the exception: Mixpost registers that application itself against the instance you name, so it is the network you can connect this afternoon.
- The container ships a standing default account. It creates admin@example.com with a password printed in upstream's install guide, and its start script recreates that account on any start where the address is missing from the users table. The install here signs in with it once over loopback within seconds of first boot, replaces the password with one generated on your server, and asserts that the published credential is refused. It also tells you never to rename that account, because renaming it is what brings the default back.
- APP_KEY is the whole vault. Every stored provider secret and social token is encrypted with it, upstream advises against ever changing it, and a database restored without the .env that made it is a table of unreadable tokens. Back up the dump and the .env together or neither is worth having.
Where this came from
“However, social networks like Facebook, LinkedIn, and TikTok may impose rate limits. If you reach a platform's limit, you can request a higher limit directly from them, though some may charge a fee for this.”
- Upstream's pricing page states the edition split directly: Mixpost Lite publishes to Facebook Pages, X and Mastodon, while Instagram, LinkedIn, YouTube, TikTok, Pinterest, Threads, Bluesky and Google Business Profile are Pro and Enterprise, sold as one-time licences at $299 and $1,199. source
- The upstream Docker guide ends by printing the credentials the container seeds on first start, the address admin@example.com with the password changeme, and tells the reader to change it in the interface afterwards. source
- Every environment variable this install sets is on upstream's Lite reference: APP_KEY, APP_URL, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, REDIS_HOST, REDIS_PORT and MIXPOST_DISK. source
- Upstream advises against ever changing APP_KEY, because it encrypts the stored service credentials and social account tokens and altering it means re-entering every one of them. The same page notes the Admin Console status screen is available only with the Pro and Enterprise packages. source
- The licence file at tag 2.6.0 is the MIT licence, copyright Dima Botezatu and Inovector. source
- The repository describes itself as the Lite version of Mixpost Pro, a commercial product, and asks contributors to keep Lite features distinct from those in Mixpost Pro. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Hootsuite?
Not Hootsuite itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Mixpost. A posting calendar for Facebook Pages, X and Mastodon on your own box, with the developer apps registered in your name and no per-seat bill. The install is one weekend: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 240 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Hootsuite?
Mixpost. A posting calendar for Facebook Pages, X and Mastodon on your own box, with the developer apps registered in your name and no per-seat bill. The calmest self-hosted answer here, and the narrowest. Three containers, MIT at the pinned tag, and no external service in the core loop: a calendar, per-network post versions, a media library and basic analytics, running on a box you already pay for. The catch is stated on the vendor's own pricing page rather than hidden: the free Lite edition publishes to Facebook Pages, X and Mastodon only, and Instagram, LinkedIn, TikTok, YouTube and the rest are the paid Pro and Enterprise editions at $299 and $1,199 one-time. Two more things to know before you start. The free repository is quiet, with release 2.6.0 dated March 2026 and no commit on main since. And the container seeds a published default password on first boot, which our install rotates within seconds and proves refused. If your posting is Facebook Pages, X and Mastodon, this replaces a Professional seat outright. If it is Instagram-first, it does not, at any price you can reach for free. Also evaluated and ranked below it: Postiz. The prompt on this page installs Mixpost only. Mixpost is MIT-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Hootsuite?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Mixpost itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Hootsuite Professional, $199/mo — $2,388 a year, 1 seat assumed.
How hard is it really?
ONE WEEKEND — 3–24 hours. The rule that produced that verdict: a database plus one outside integration. A database and an outside service fail in completely different ways, and you have to learn both failure modes before you trust the thing with real data. Budget the second day for whichever one surprises you. The tier is derived from seven countable facts about the Mixpost install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Mixpost on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Mixpost on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Everything answers at http://localhost:8197, so a post scheduled for 9am goes out only if the computer is awake at 9am, and the callback URL Mixpost hands a developer portal is a localhost address that X and Meta refuse, which leaves Mastodon as the network this path can actually connect. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.