Static on DirectAdmin: GET Returns 404 but HEAD Returns 200
HEAD and GET returning different responses is no mystery. It is a sign that something sits between the web server and the files, making different decisions for the two request paths.
I had just finished a calculator site, entirely static HTML and JavaScript, with the calculations running in the browser. No database, no backend at all. The whole thing was under six hundred kilobytes. I thought this would take 5 minutes: upload public_html and start using it. Instead, I opened it to a blank page, checked devtools, and saw every js file returning 404.
What dragged this out was tracking down the cause. I ran curl -I to check whether the files were there, and it returned a clean 200 with content-length matching the file size exactly. Back in the browser, I still got 404. Those two conflicting answers cost me more time than they should have. I wrote this article so anyone seeing the same symptoms would not have to waste that time again.
The domain names, accounts, and keys in this article are example values. The symptoms and fixes are real.
Symptoms that make no sense at first
First, I needed a complete record of the symptoms, because this set of results points most of the way to the cause if you know how to read it.
The most misleading part was "small files work, large files do not," because it sent me thinking about size, limits, and buffers, all in the wrong direction. I spent quite a while working through everything from LimitRequestBody to mod_deflate without getting anywhere.
# ไฟล์เดียวกัน สองวิธี สองคำตอบ
curl -sI https://calc.example.com/engine.js | head -n3
# HTTP/2 200
# content-type: text/javascript
# content-length: 30214
curl -s -o /dev/null -w '%{http_code}\n' https://calc.example.com/engine.js
# 404
At that point, I did not yet know that file size was not the real dividing line. So I started with the most basic questions instead: were the files actually on the machine, and could the web server see them?
ssh -p 2233 user@host 'ls -la ~/domains/example.com/public_html/ | head -n20'
# ไฟล์ครบทุกตัว สิทธิ์ 644 เจ้าของถูก
# ยิงตรงจากในเครื่องเอง ตัด CDN กับ proxy ข้างนอกออกให้หมดก่อน
ssh -p 2233 user@host \
'curl -sSk --resolve calc.example.com:443:127.0.0.1 -o /dev/null -w "%{http_code}\n" https://calc.example.com/engine.js'
# 404 <- ยังพังจากในเครื่อง แปลว่าไม่ใช่เรื่องของ CDN
If the domain runs through a CDN or proxy, do not draw conclusions from requests sent from your own machine yet. Use ssh to get onto the server and send a request from there with --resolve pointing back to 127.0.0.1 first. If the result is the same, the problem is on the server. If it differs, the problem is in an outer layer. This question takes ten seconds to answer and cuts the guesswork in half.
The cause — .htaccess in the wrong place
Once I knew the problem was on the server, I checked what I should have checked at the start: .htaccess at the docroot. It turned out that this account's main domain had previously run Laravel, and Laravel's .htaccess file was still there.
# ~/domains/example.com/public_html/.htaccess (ของเดิมที่เหลือค้างจาก Laravel)
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
At a glance, these rules look safe enough, because the !-f condition means an existing file will not be rewritten. But what actually happened on that machine was that requests went to index.php, where the app's router could not find a matching route and returned 404, even though the file was sitting right there.
Options -MultiViews was the other half of the story. MultiViews is an old Apache mechanism that lets you request /report and have it fetch report.html for you. Laravel disables it to avoid conflicts with its own routes. As a side effect, file lookup at this docroot behaves differently from other folders on the same machine, which explains why paths without extensions still worked.
My observation that "small files pass, large files fail" was a coincidence in the set of files I tested. The small files that passed happened to have paths matching the app's routes. The large files that failed did not. Do not infer a rule from an incomplete set of examples. I burned a lot of time on the file size hypothesis when I should have opened .htaccess in the first minute.
As for HEAD returning 200, I did not trace it far enough to determine exactly which layer was responsible: the edge cache or the web server itself. The lesson I took from it was to stop using curl -I to check whether a file exists. Send an actual GET request and check the status and size returned instead. This is the command I use now.
# เช็กแบบที่เชื่อได้ — GET จริง แต่ทิ้ง body
curl -s -o /dev/null -w 'code=%{http_code} size=%{size_download} type=%{content_type}\n' \
https://calc.example.com/engine.js
Fix one — move it under a subdomain
Once I knew the cause, there were three fixes to choose from. I tried all of them, so I will cover which one suits which situation. All three keep things on DirectAdmin, with no need to change hosts.
The first and easiest is to move it under a subdomain, because a subdomain's docroot is a separate folder from the main domain's, with no Laravel .htaccess sitting over it. I tested this on a subdomain with four file sizes, and every one returned 200 with the correct content-type.
# ทดสอบบน subdomain — ไฟล์ทุกขนาดผ่านหมด
for f in page.html app.js style.css shot.png; do
curl -s -o /dev/null -w "$f -> %{http_code} %{size_download} %{content_type}\n" \
https://demo.example.com/$f
done
# page.html -> 200 40118 text/html
# app.js -> 200 30214 text/javascript
# style.css -> 200 20447 text/css
# shot.png -> 200 1638400 image/png
Fix two — exempt static files before the rewrite rules
The second option is to edit .htaccess at the docroot to exempt static file extensions before reaching the app's rewrite rules. This works if the main domain still needs to run Laravel and I just want to add some static content alongside it.
# แทรกไว้ "ก่อน" กฎ rewrite เดิม
RewriteEngine On
RewriteRule ^(assets|static)/ - [L]
RewriteCond %{REQUEST_URI} \.(?:js|css|png|jpe?g|svg|webp|woff2?|ico|json|map)$
RewriteRule ^ - [L]
Fix three — serve files through a small php script
The third option is to serve the files through a small php script, which I used temporarily while I could not fix the main domain. This reads files from a folder the front controller leaves alone and sends them out itself.
<?php
// public_html/a.php — เสิร์ฟไฟล์ static ผ่านทาง .php ที่ front controller ไม่แตะ
$base = __DIR__.'/asset/';
$f = $_GET['f'] ?? '';
// กัน path traversal: ยอมเฉพาะรูปแบบที่กำหนด และต้องอยู่ใต้ base จริงหลัง realpath
if (!preg_match('#^[A-Za-z0-9._/-]+$#', $f) || str_contains($f, '..')) {
http_response_code(400); exit;
}
$path = realpath($base.$f);
if ($path === false || !str_starts_with($path, realpath($base).DIRECTORY_SEPARATOR)) {
http_response_code(404); exit;
}
$types = ['js'=>'text/javascript','css'=>'text/css','png'=>'image/png',
'svg'=>'image/svg+xml','json'=>'application/json','woff2'=>'font/woff2'];
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
header('Content-Type: '.($types[$ext] ?? 'application/octet-stream'));
header('Content-Length: '.filesize($path));
header('Cache-Control: public, max-age=31536000, immutable');
readfile($path);
Which option to choose
All three options above resolve the symptoms. The remaining question is whether this site should stay on the existing host at all. I weighed that against one criterion: "What does this site depend on that machine for?" If the answer is nothing, forcing it to stay means taking on maintenance with nothing in return.
Suited to things tied to the existing host
No need to move anything, and I can keep the existing domain and certificate. But I have to accept that every file goes through php or rewrite rules that need ongoing maintenance, and one mistake editing .htaccess takes down the entire domain.
Suited to purely static sites with no backend
No web server to deal with, a free CDN included, and deployment with one command. But I have to move DNS and learn another platform's rules.
Why I eventually moved to edge hosting
This site really was entirely static, with nothing that needed even a single line of php. I decided it was not worth forcing it to stay on a machine where I had to keep watching out for .htaccess. Moving it to a static hosting service at the network edge was a more straightforward way to finish the job.
But there was one thing that needed a backend: the report button that lets users send messages and images. The platform I chose offered two ways to do this. I first tried the functions folder approach recommended in the documentation, and it did not work.
I put the file in the functions/ folder as the documentation instructed. The deployment tool reported that everything had succeeded, but it had not compiled the function at all. It simply uploaded the entire folder as static files. The result was 405 when I called the endpoint, even though the deployment log did not contain a single error. This is another kind of silent failure, where the success indicator measures something different from what I need.
What worked was advanced mode, which uses a single worker file at the root of the deployment folder, with all the routing written manually. Anything that is not an endpoint path gets handed back to the static file server. During deployment, a line confirms that the worker compiled successfully, which is a signal I can actually trust.
// _worker.js — วางที่รากของโฟลเดอร์ที่ deploy
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/report' && request.method === 'POST') {
const form = await request.formData();
const answer = form.get('cf-turnstile-response');
const verify = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ secret: env['TURNSTILE_SECRET'], response: answer }),
}).then(r => r.json());
if (!verify.success) return new Response('bad token', { status: 403 });
await sendMail(env, form); // ยิงเมลผ่านผู้ให้บริการเมล
return new Response(JSON.stringify({ ok: true }), {
headers: { 'content-type': 'application/json' },
});
}
// ที่เหลือปล่อยให้ตัวเสิร์ฟไฟล์ static ทำงานตามปกติ
return env.ASSETS.fetch(request);
},
};
Deployment traps that will cost you time if you do not know about them
I ran into all three of these in one day. I am keeping them here for anyone following along.
- When adding a custom domain to the project, there is a period while the system issues the certificate when opening the site returns a proxy-layer error. Do not panic and start tearing apart DNS. Wait a minute or two, and it will go away on its own.
- After every deployment, purge the edge cache. Otherwise, verification will return old content and make it look as though the deployment did not go through. This fooled me twice before I remembered.
- The bot protection in front of the form cannot be tested end-to-end with a script, because blocking exactly that is its job. I have to open a real browser and click through it manually.
# deploy แล้วล้าง cache ต่อทันทีในสองบรรทัด อย่าแยกจำ
npx wrangler pages deploy ./dist --project-name mysite --branch main --commit-dirty=true
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" \
-H 'Content-Type: application/json' \
--data '{"purge_everything":true}' | head -c 200
One — send an actual GET request when checking whether a file exists. Do not use HEAD, because the two request paths may be handled in different places. Two — when something strange happens at the docroot, open .htaccess first, before suspecting size, permissions, or the CDN. Three — if the site is entirely static, do not force it to stay somewhere controlled by a front controller. Moving it out from the start saves a lot of trouble.
For anyone whose main domain still needs to run Laravel and who wants to add static content alongside it, the second fix is probably the best fit. For anyone with an entirely static site like mine, go ahead and move it out without regrets. The rest is up to you. If I missed anything or made any typos, I will come back and fix them later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first