How We Host StoryShort: Next.js, Postgres and a Video Worker on One Droplet With Kamal
The real setup behind storyshort.app: one DigitalOcean droplet, Docker containers deployed with Kamal, and the deploy mistakes that taught us the most.
Some links on this page are paid or referral links (Claude), marked next to each link. If you sign up through one, we may earn a commission or usage credit at no extra cost to you. We only add paid links for tools we use ourselves.
People are often surprised that StoryShort does not run on a cloud platform with a dozen managed services. It runs on one virtual server. This post is the honest version of our setup: what runs where, how we deploy, what it costs and the mistakes that cost us the most time. It is written for developers and indie founders, not for story creators, so feel free to skip it if you came here for videos.
The setup
- One server: a DigitalOcean droplet with 16 GB of RAM and 320 GB of disk. It started smaller and has been resized since.
- Several apps on it: StoryShort and its sister products AutoShort and RemixViral, each a Next.js app in its own Docker container, each with its own Postgres database.
- A shared video worker: a separate container that runs ffmpeg to mix narration, visuals and captions into the final video. The web apps hand it jobs.
- Files on disk: rendered media lives on the droplet's volume and is served by the app.
- Kamal builds the images and swaps containers with zero downtime, behind its own proxy that handles TLS.
Why one box
Cost is the obvious reason, but not the main one. The main one is that a single machine is easy to reason about. When something is slow, there is one place to look. When something breaks at 2 a.m., there are no cross-service permissions, queues or managed-database quotas to rule out first.
The trade-offs are real and we accept them knowingly: it is a single point of failure, a heavy render can slow the web apps, and scaling means moving to a bigger machine rather than adding instances. At our size those are cheaper problems than the complexity of the alternative.
How a deploy works
- Merge to main after tests pass on the merged code.
- Run our deploy script. It builds the Docker image locally, pushes it to a registry, and asks Kamal to boot the new container next to the old one.
- Kamal waits for the new container's health check, then switches traffic and stops the old one.
- The script runs database migrations, then we check the live site by hand.
Most of our deploy scripts and the fixes to them were written with Claude (referral link: 7-day guest pass; we may receive usage credit) Code. It is good at shell scripts and Kamal config, and very good at reading a failed deploy log and finding the one line that matters. We still review every change, because a confident wrong fix to a deploy script is how you take a site down.
Five lessons from real deploys
1. A bare kamal deploy does not run migrations
Kamal ships containers; it does not know about your database. Our migrations live in the wrapper script. When deploys went out with plain kamal deploy to save a step, the code shipped and the schema did not, and the migrations had to be applied by hand afterwards, which is exactly the kind of step that gets forgotten. Now the rule is simple: deploy only through the script, and apply additive database changes before the code that needs them.
2. Long builds and idle SSH connections
A Next.js build can take long enough for an idle SSH connection to the server to be reset, and the deploy then fails after the build succeeded. The fix was a connection pool idle timeout in Kamal's SSH settings, so stale connections are dropped and reopened instead of reused.
3. A cached empty build
Once, an interrupted build produced an empty build step that still exited successfully, and Docker cached it. Every later deploy reused the empty layer and shipped a broken app. The lesson: if a deploy “succeeds” but the site is wrong, suspect the build cache and clear that one record rather than everything.
4. Memory limits are not optional
On one machine shared by several apps, every container needs a memory limit. Ours are written into the Kamal config: 1.5 GB for the StoryShort web app and 512 MB for its database, for example. Without limits, a single heavy job can push the whole machine into swapping, and every app slows down together; with them, the heavy job fails on its own and the error tells you which one it was.
5. Deploy one app at a time
Two deploys at once compete for CPU during the build and for memory during the switch. Serialising them is slower on paper and faster in practice.
Seeing production errors
A single server makes logs easy to find, but logs are not alerts. Every failed render and every unhandled error is reported to an error tracker, so we hear about a problem before a customer emails. The rule we hold ourselves to: if a video fails, someone gets told, even when the cause is the user's input rather than our code.
When we will outgrow it
The first thing to move off the box will be the video worker, because rendering is the heaviest and spikiest workload. After that, the databases, onto a managed service once backups and failover are worth paying for. We will not move anything just because a bigger architecture looks more serious.
If you want to copy it
- Start with one droplet sized for your heaviest process, not your average one.
- Give every container a memory limit and a health check.
- Wrap Kamal in a script that also runs migrations and a smoke test.
- Back up the database off the machine, and test a restore at least once.
- Report errors somewhere that notifies you; do not rely on reading logs.
It is not glamorous, and that is the point: the less time the infrastructure takes, the more time goes into the product.
Written by StoryShort Team