A developer uploads a single index.php file to sh0. The platform rejects it: "Cannot generate Dockerfile for unknown stack." She switches to a static HTML site. It builds, but fails with: "Container health check reported unhealthy." No explanation. No logs. Just a red badge and a one-line error.
She opens Docker Desktop, digs through container logs, and finds: nginx: [emerg] open() "/run/nginx.pid" failed (13: Permission denied).
The platform knew what went wrong. It just refused to tell her.
This is the story of three bugs that were actually one design failure -- and how fixing them forced us to rethink what "deploy and forget" means for developers who have never touched Docker.
The Three Layers of Silence
Layer 1: Docker Build Logs Were Discarded on Failure
When a Docker build succeeds, sh0 stores every line of output. When it fails, it stored nothing. Just the error message.
Here is what we showed:
[STEP 3/6] Building Docker image
[ERROR] Docker build failed: Docker error: Timeout: Image build timed out after 600sHere is what Easypanel showed for the exact same failure:
#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 3.43kB done
#1 DONE 0.0s
#8 [builder 3/9] RUN apt-get update && apt-get install -y ...
#8 CACHED
#12 [builder 8/9] COPY ../src ./src
#12 ERROR: "/src": not foundThe second version tells you exactly what went wrong: the Dockerfile references a path outside the build context. The first version tells you nothing.
The root cause was in our Docker client. When parsing the streaming JSON response from the Docker API, we collected log lines into a Vec<String>. But when the API reported an error, we returned the error and discarded the entire vector:
rustif let Some(error) = output.error {
return Err(DockerError::Build(error));
// logs is dropped here -- all the useful output is gone
}The fix: make the error carry the partial logs with it.
rustif let Some(error) = output.error {
return Err(DockerError::Build {
message: error,
partial_logs: logs, // everything collected before the failure
});
}This required threading partial_logs: Vec<String> through the error chain -- from DockerError to BuilderError to the deployment pipeline. The pipeline now extracts and stores partial logs in the database before propagating the error upward.
Layer 2: Container Crashes Were Invisible
Docker build succeeding does not mean the application works. The container can crash at startup. In our case, nginx was crashing with Permission denied -- but sh0 only showed:
[ERROR] Container health check reported unhealthyNo reason. No container output. You had to open Docker Desktop and read the logs manually. For a platform whose entire value proposition is "you don't need to touch Docker," this is a contradiction.
The fix was a four-line function:
rustasync fn fetch_container_logs(docker: &DockerClient, container_id: &str) -> String {
match docker.container_logs(container_id, Some(50), None, false).await {
Ok(logs) if !logs.trim().is_empty() => {
format!("\n[Container logs]\n{}", logs.trim())
}
_ => String::new(),
}
}Now when a health check fails, the deployment tab shows the actual crash reason:
[ERROR] Container health check reported unhealthy
[Container logs]
nginx: [emerg] open() "/run/nginx.pid" failed (13: Permission denied)This is the fix that diagnosed Layer 3.
Layer 3: nginx Cannot Run as Non-Root Without Help
sh0 forces every container to run as uid 1000:1000 -- a security decision. But the generated nginx Dockerfile assumed root privileges. Three things broke:
/var/cache/nginx/client_temp-- nginx cannot create its cache directory/run/nginx.pid-- nginx cannot write its PID file- Port 80 -- non-root users cannot bind to ports below 1024
The fix required rewriting the generated Dockerfile for static sites:
dockerfileRUN mkdir -p /var/cache/nginx/client_temp \
/var/cache/nginx/proxy_temp \
/tmp/nginx \
&& chown -R 1000:1000 /var/cache/nginx \
&& chown -R 1000:1000 /run \
&& chown -R 1000:1000 /etc/nginx \
&& sed -i 's|/run/nginx.pid|/tmp/nginx/nginx.pid|' /etc/nginx/nginx.conf \
&& sed -i '/^user /d' /etc/nginx/nginx.confPort 80 became port 8080 internally. The reverse proxy (Caddy) handles the external port mapping, so users never see this.
The cPanel Problem
With the three layers of silence fixed, we hit a bigger question: why did the PHP file fail to deploy at all?
sh0's stack detector looked for composer.json to identify PHP projects. No composer.json, no PHP detection. The file fell through to "Unknown" and was rejected.
This is a Silicon Valley blindspot. The detector was designed by someone (me) who thinks about PHP in terms of Laravel and Symfony -- frameworks with composer.json, PSR-4 autoloading, and public/ directories.
But millions of developers deploy PHP without Composer. They upload files to cPanel. They do not have a Dockerfile. They do not have a composer.json. They have index.php and they expect it to work.
The Detection Fix
We added any_file_with_ext(dir, "php") as a fallback after the composer.json check. Then we built a full framework sub-detection system, following the pattern we already had for Node.js (Next.js, Nuxt, SvelteKit) and Python (Django, FastAPI):
| Priority | Check | Framework | Document Root |
|---|---|---|---|
| 1 | wp-config.php | WordPress | root / |
| 2 | artisan file | Laravel | public/ |
| 3 | bin/console + config/bundles.php | Symfony | public/ |
| 4 | spark file | CodeIgniter 4 | public/ |
| 5 | bin/cake | CakePHP | webroot/ |
| 6 | composer.json requires yiisoft/yii2 | Yii 2 | web/ |
| 7 | composer.json requires slim/slim | Slim | public/ |
| 8 | No framework | Generic PHP | root / |
The Dockerfile Fix
The PHP Dockerfile generator now branches on three variables:
- Has composer? If yes, include a
FROM composer:2 AS depsbuild stage. If no, skip it entirely. - Document root? Nginx
rootdirective varies per framework:/var/www/html/publicfor Laravel,/var/www/html/webrootfor CakePHP,/var/www/htmlfor bare PHP. - Extensions? Laravel needs
bcmath,mbstring,xml,tokenizer,fileinfo. WordPress needsgd,zip. Generic PHP gets the baseline:pdo,pdo_mysql,mysqli,opcache.
A bare index.php now generates a clean, single-stage Dockerfile with no unnecessary complexity.
What We Learned
1. Error messages are a product surface
The deployment log is not a debugging tool for engineers. It is the primary interface for users who do not understand Docker. Every line of output that we hide is a support ticket waiting to happen.
Easypanel understood this. We did not -- until a $5/month user nearly demanded a refund because she could not figure out why her site would not deploy. The error was Permission denied on an nginx cache directory. We knew. She did not.
2. Security defaults must be tested with generated code
Running containers as non-root is correct. But if your platform generates the Dockerfile, you own the responsibility of making that Dockerfile work under your security constraints. We tested the security policy. We did not test the Dockerfiles we generate under that policy.
3. Detect what users have, not what frameworks expect
The developer in Lagos does not use Composer. She does not use Laravel. She has index.php and she expects it to work. Our stack detector was optimized for the developer who already knows Docker -- exactly the person who does not need our platform.
The Numbers
| Metric | Before | After |
|---|---|---|
| Build failure information | 1-line error | Full Docker build output |
| Container crash diagnosis | Open Docker Desktop | Inline in deployment tab |
| Static site deploy (nginx) | Broken (Permission denied) | Works (non-root, port 8080) |
| Bare PHP file deploy | Rejected ("unknown stack") | Detected and deployed |
| PHP frameworks detected | 0 | 7 (Laravel, Symfony, WordPress, CodeIgniter, CakePHP, Yii, Slim) |
| Builder test count | 119 | 126 |
| Files changed | -- | 9 files across 4 crates + dashboard |
What This Means for sh0
sh0 is a deployment platform for people who should not need to understand deployment. Every time we expose Docker internals -- whether through cryptic errors, missing logs, or stack detection that only works for framework users -- we betray that promise.
These fixes are not features. They are corrections. The platform should have worked this way from the beginning.
The next step: Audit Round 1 (a fresh Claude session reviews everything), then Audit Round 2 (a third session verifies the fixes). This is our standard methodology -- build, audit, audit, decide. No single session has the full picture.
This post was drafted during the session that implemented these changes. The code is real. The errors are real. The developer in Lagos is a composite, but her problem is not.