Every night, while no one is watching, this system reads real facts from a website network, writes finished social posts from those facts, and publishes them — on two small servers, for the price of one dinner a month.
The one-sentence version. One server holds the website data and builds a nightly "feed" of verified facts; a second server turns that feed into finished posts using fixed templates and publishes them to your social accounts. The split exists for one reason — memory — and everything else follows from keeping those two jobs on two separate boxes.
Big picture: two servers, and why
The whole system lives on two DigitalOcean servers (DigitalOcean calls them "droplets"). They do two very different jobs, and keeping them apart is the single most important architectural decision in the design.
MAIN (IP 143.198.182.180) is the older, busy server. It hosts the roughly 190-site website network and all the source data those sites are built from — site descriptions, AI-readiness scores, the marketing-skills library. Because it is already doing a lot, it is memory-constrained. Once a night it does one small extra job: it builds the content feed — a compact file of verified facts — and ships that file to the other server.
HUB (IP 64.227.29.192) is the publishing brain. It is a modest 4GB / 2-vCPU box, Ubuntu 24.04, about $24 a month. It runs the social-publishing app and the custom logic that turns feed facts into scheduled, published posts. You reach it in a browser at https://postiz.wholetech.com.
Why split them at all? The publishing app runs several services at once — a database, a cache, a scheduler, the app itself — and that stack wants real memory. MAIN doesn't have it to spare. Rather than risk the website network going wobbly whenever a publishing job spiked, the publishing stack got its own clean, cheap box. MAIN stays fast and untouched; the HUB can be rebooted, upgraded, or rebuilt without the websites ever noticing.
There's a bonus: isolation of blast radius. A bad deploy on the hub can't take the websites down, and heavy website traffic can't stall a publish. Two jobs, two boxes, two failure domains.
Under the hood
You log into the hub with ssh root@64.227.29.192 and into main with ssh root@143.198.182.180 (SSH-key only — see Security). The hub must be reached over HTTPS: the login cookie will not stick over plain http, so always use the https:// address.
The component catalog
Everything here is open-source and self-hosted — nothing rented, nothing metered by an outside vendor. Here is every moving part, what it does in plain terms, and which server it runs on.
Inside the Portal (/opt/portal/)
The Portal is the custom brain. Postiz can publish; the Portal decides what to publish, when, and whether it's honest enough to send. Its key files:
For how these are operated day to day, see Daily Operations; for the content templates themselves, see The Content Engine.
The nightly pipeline
Nothing here is manual. A chain of scheduled jobs (cron) runs every night in a fixed order. Each hands off to the next. All times are UTC.
04:17 — HUB
Backup. backup.sh copies the database, portal data, and config off-box to cloud object storage before the day's work begins.
↓
05:00 — MAIN
Build the feed. gen-social-feed.py reads the real website data, assembles the feed of verified facts, and scp's it to the hub.
↓
05:10 — HUB
Draft the posts. generate.py reads the fresh feed, runs it through the templates, and writes finished draft posts into the queue in portal.db.
↓
every 10 min — HUB
Publish what's due. publish_due.py wakes up, finds approved posts whose scheduled time has arrived, and sends them — respecting the X daily cap and the PAUSE switch.
↓
05:20 — HUB
Report. report.py writes a digest of what was drafted, held, and published.
Where to look when something's off. Every stage logs. On MAIN:
/var/log/social-feed.log. On the HUB:
/var/log/portal-gen.log (drafting),
/var/log/portal-pub.log (publishing),
/var/log/portal-rep.log (report). See
Troubleshooting for reading them.
Notice the shape: the feed is built before drafting, drafting happens once a night, and publishing runs continuously on a 10-minute heartbeat so scheduled posts go out close to their intended time rather than in one nightly dump.
The journey of one post
Abstractions are easy to nod along to and hard to trust. So let's follow a single real post from raw data to the timeline.
1. source data
A fact exists. On MAIN, /root/wt-sites.json holds a site's domain and a one-line description — say a site and the sentence that describes what it does.
↓
2. feed item
It becomes a feed item. At 05:00 gen-social-feed.py turns that entry into a feed item tagged with a kind — here, site_feature — carrying only verified fields. The feed is scp'd to the hub.
↓
3. template match
A template claims it. At 05:10 generate.py matches the item to a site_feature template. The template declares the fields it needs — and every one is present, so it fills in the fixed sentence pattern.
↓
4. honesty guard
The guard checks it. Before the draft is accepted, the guard confirms no required field is missing. If one were, the post would be held — never guessed. Ours passes.
↓
5. scheduled draft
It's queued with a time. The finished post lands in portal.db as a draft with a scheduled send time, sized under the ~278-char cap.
↓
6. approval
It's approved. For the owner's own network the tenant is set to auto-fire, so it's approved automatically. A client tenant would instead get a magic link to Approve / Edit / Skip.
↓
7. publish
It goes live. Within 10 minutes of its scheduled time, publish_due.py sends it to the connected channels — X and Bluesky for this tenant.
↓
8. report
It's counted. The nightly report.py digest records it as published, so the loop is closed — you can see what actually went out.
The honesty guard is the hero of this story. If that site description had been missing a field the template needed, the post would have been shown and flagged but never sent. The machine will hold a post before it will invent a fact. That is what makes it safe to run for paying clients — more in
The Content Engine.
The post state model
Every post carries a state that says exactly where it is in its life. Understanding these states is understanding the system's behavior — there are no hidden actions, only transitions between these labels.
How a post moves
The normal path is pending → approved → published. The honesty guard diverts a post to held at draft time. A reviewer can send a post to skipped, or Edit it (which keeps it pending with new wording) then approve. An approved post that the channel refuses becomes failed. In a dry tenant, an approved post reaches published_dry instead of published. Nothing moves backward on its own — every transition is either a scheduled job doing its narrow job, or a human decision.
How scheduling works
Scheduling has two layers, and it helps to keep them straight.
The outer layer is cron — plain server scheduling that decides when the Portal's own jobs run (build feed at 05:00, draft at 05:10, publish check every 10 minutes, report at 05:20). This is the pipeline you saw above.
The inner layer is Temporal, which Postiz uses internally to manage the scheduling and execution of the actual publish operations. It runs as a dev server on port 7233. It matters here for one blunt operational reason: Postiz depends on it, and without it Postiz falls over.
Temporal must be up. If the Temporal container is down, Postiz can't reach it (ECONNREFUSED :7233), crash-loops, and nginx returns a 502 on /api. If the hub UI is throwing 502s, the first thing to check is whether Temporal is running. This is the most common self-inflicted outage on the hub.
Why a dev server?
Temporal is normally a heavyweight, clustered piece of infrastructure. On a $24 single-purpose box that would be overkill, so it runs in its lightweight dev-server mode — enough to satisfy Postiz's dependency and keep the publishing brain happy, without standing up an enterprise scheduling cluster to post a few times a day. It's the right amount of machinery for the job.
How the feed ships from main to hub
This is the seam between the two servers, and it is deliberately simple.
The feed is built where the data lives. Only MAIN has the ~190 sites and their source files, so only MAIN can assemble a trustworthy feed. At 05:00 UTC, gen-social-feed.py reads that data, builds the feed, and copies it over to the hub — landing at /opt/portal/listings/wholetech.json, the owner-network tenant's listings file.
From that moment the hub is self-sufficient for the night: it never reaches back into MAIN to draft or publish. It just reads the file it was handed. That one-way handoff is what lets the two boxes fail independently — if MAIN is busy or the copy is late, the hub still has yesterday's feed to work from and simply publishes nothing new rather than breaking.
Why a file, not a live API call? A copied file is the most robust integration there is: no shared uptime requirement, no auth handshake mid-pipeline, nothing to rate-limit. The feed is a snapshot of verified facts at 05:00, and everything downstream reasons about exactly that snapshot. Simple, auditable, and hard to break.
Data isolation & multi-tenancy
The same engine runs the owner's own network and paying clients side by side, but each tenant is walled off. A tenant is simply one block in clients.json, and everything about that tenant is kept separate.
- Config is separate. Each tenant's block defines its own brand and tagline, cadence, channels, approval requirement, template module, and listings path. Changing one tenant never touches another.
- Content is separate. Each tenant draws only from its own
listings/<tenant>.json file. The owner network reads wholetech.json; a client reads theirs. Facts never cross tenants.
- Templates are separate. A tenant points at its own template module — the network self-promo templates for the owner (
portal_templates_wt.py), the builder-demo templates for others.
- Access is separate. Each approval tenant gets its own private, HMAC-signed, expiring magic link to its own phone-friendly queue. One tenant's link can't see another's posts.
- Channels & tokens are separate. Connected accounts and their access tokens live per-integration in the hub database, tied to the tenant's organization — not shared, not in code.
Two tenants exist today: wholetech (the owner's own network — channels X and Bluesky, cadence 7 ≈ one post a day, auto-fire) and demo (a builder demo on the dry channel, so it logs without ever sending). Adding a client is adding a block, a listings file, and a magic link — the full walkthrough is on Managing Clients.
Scale & limits
A fair question from any partner: how far does one small box go? Further than you'd think, because the expensive part of most "AI marketing" tools — calling a language model for every post — simply doesn't happen here.
The hub is 4GB / 2-vCPU. Its steady-state workload is a handful of always-on containers (Postiz, PostgreSQL, Redis, Temporal) plus small Python jobs that run for seconds a night. Drafting is deterministic template-filling, so generating 1,000 posts costs the same as generating one: effectively nothing, and takes almost no time. The real-world throughput ceiling is not the box — it's the platforms' own rate limits and, for X, your budget.
The cost doesn't climb with scale. The whole system is $24/month, flat. Content generation is free because it's template-based with no per-token AI billing. The only paid channel is X (~1.5¢ a post, more with a link), and even that is capped in software. Adding tenants adds config, not cost.
Where you'd add capacity
If you outgrew one hub, the growth path is horizontal, not a bigger single box:
- More tenants, same hub — the first and cheapest move; the hub has plenty of headroom for many client blocks.
- A second hub — because the design is already two-box and file-driven, spinning up another publishing hub with
doctl is straightforward when one gets busy.
- More channels per tenant — adds reach without adding servers; each channel just needs its own connection (see Connecting Social Platforms).
The practical near-term limits are platform-side: X's per-post cost and the daily cap, and each network's API terms — not the hardware.
Design principles — why it's built this way
Every choice above traces back to a few convictions. They're worth stating plainly, because they're also the differentiators against the flood of generic "AI slop" tools.
Deterministic templates = token-free
Posts are written by fixed sentence patterns filling verified fields — not by asking a language model to write something fresh each time. That's not a limitation, it's the point: output is predictable, on-brand, instant, and free at any volume. There is no per-post AI bill to watch climb, and no risk of a model hallucinating a claim. For rotation and variety across the fixed patterns, see The Content Engine.
Separate boxes, separate failures
Publishing lives on its own cheap, disposable box so the website network can't be dragged down by it and vice versa. The hub can be rebuilt from backup without the sites ever noticing. Two jobs, two failure domains — a resilience choice disguised as a cost choice.
The honesty guard is enforced by the machine, not by trust
The core promise — never state a fact it can't verify — isn't a policy someone remembers to follow. It's code. A template declares its required fields; a missing field means the post is held, mechanically, every time. That's why this is safe to run unattended and safe to sell as a service.
What this adds up to. Real data instead of invented copy. An honesty guard that would rather hold a post than guess. Flat economics that don't punish you for growing. A human approval option when you want it. And a system that closes the loop — it publishes and reports, it doesn't just draft. That combination is the whole argument.
From here: see Daily Operations for running it day to day, Playbooks for every task step by step, and Security, Privacy & Compliance for how it's locked down.