<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Niloy Nath</title>
    <description>The latest articles on DEV Community by Niloy Nath (@piplustheta).</description>
    <link>https://dev.to/piplustheta</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4024599%2F9bd9fc3e-70b3-40a0-bb4c-4cbcfe27c931.gif</url>
      <title>DEV Community: Niloy Nath</title>
      <link>https://dev.to/piplustheta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/piplustheta"/>
    <language>en</language>
    <item>
      <title>MinIO Was Easy, SeaweedFS Made Me Think</title>
      <dc:creator>Niloy Nath</dc:creator>
      <pubDate>Fri, 10 Jul 2026 23:51:25 +0000</pubDate>
      <link>https://dev.to/piplustheta/minio-was-easy-seaweedfs-made-me-think-21a1</link>
      <guid>https://dev.to/piplustheta/minio-was-easy-seaweedfs-made-me-think-21a1</guid>
      <description>&lt;h3&gt;
  
  
  Why I wrote this
&lt;/h3&gt;

&lt;p&gt;After I got one app working on MinIO, I thought I understood the self-hosted storage problem.&lt;/p&gt;

&lt;p&gt;That confidence lasted exactly until the next deployment.&lt;/p&gt;

&lt;p&gt;The new constraint looked similar: no S3, no managed object storage, media-heavy app, private/generated files, FastAPI backend, browser-facing URLs, and enough user uploads that "just put it in Postgres" would be a crime.&lt;/p&gt;

&lt;p&gt;MinIO was the obvious first thought because it had already worked. It is S3-compatible, easy to run, and the boto3 migration story is basically one &lt;code&gt;endpoint_url&lt;/code&gt;. But by this point I was also thinking about the enterprise side of the decision. MinIO is still open source, but serious enterprise packaging, support, and newer commercial product positioning live behind MinIO's paid AIStor/subscription world. For a client-owned deployment where the whole premise was "we want control and predictable cost", I wanted to at least look at the other serious self-hosted option.&lt;/p&gt;

&lt;p&gt;That is how SeaweedFS entered the chat.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — SeaweedFS:&lt;/strong&gt; a distributed storage system originally designed around fast file lookup and efficient small-file storage. It can expose a filesystem-like filer, volume servers, and an S3-compatible API. That last part is what made it usable for my app without rewriting every upload/download path.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;MinIO felt like "S3, but on my server."&lt;/p&gt;

&lt;p&gt;SeaweedFS felt like "a storage system that happens to speak S3."&lt;/p&gt;

&lt;p&gt;That difference matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  The terms, because I will forget them
&lt;/h3&gt;

&lt;p&gt;If I come back to this in six months, this is the section I want near the top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Object storage:&lt;/strong&gt; storage for blobs addressed by keys, not rows or normal filesystem paths. Example key: &lt;code&gt;users/42/gallery/image.webp&lt;/code&gt;. The slash is part of the name; it is not necessarily a real folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucket:&lt;/strong&gt; a top-level container for objects. S3 calls it a bucket. S3-compatible systems usually keep the same word.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3-compatible:&lt;/strong&gt; not AWS S3, but speaks enough of the same API that tools like boto3, AWS CLI, and pre-signed URLs can work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;boto3:&lt;/strong&gt; the Python AWS SDK. In this project it is not "AWS code" anymore; it is just the S3 protocol client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Endpoint URL:&lt;/strong&gt; the base URL boto3 talks to. For AWS it is implicit. For MinIO/SeaweedFS it is explicit, like &lt;code&gt;http://seaweed:8333&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SigV4:&lt;/strong&gt; AWS Signature Version 4, the signing algorithm used for authenticated S3 requests and pre-signed URLs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-signed URL:&lt;/strong&gt; a temporary URL that contains a signature. Anyone with the URL can access that one object until it expires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path-style URL:&lt;/strong&gt; &lt;code&gt;https://media.example.com/bucket/key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtual-hosted-style URL:&lt;/strong&gt; &lt;code&gt;https://bucket.media.example.com/key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Filer:&lt;/strong&gt; SeaweedFS's metadata/file layer. It gives a directory-like view over files whose bytes live on volume servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Volume server:&lt;/strong&gt; SeaweedFS component that stores actual file content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Master:&lt;/strong&gt; SeaweedFS coordinator that tracks volumes and where file ids live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3 gateway/API:&lt;/strong&gt; the SeaweedFS-facing door that lets normal S3 clients upload and download objects.&lt;/p&gt;

&lt;p&gt;That is the whole mental model: my app talks S3, SeaweedFS translates that into its own filer/volume world, and the browser receives either public URLs or pre-signed URLs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not just stay on MinIO?
&lt;/h3&gt;

&lt;p&gt;I still like MinIO. For a single machine, simple object storage, and a codebase that already uses boto3, it is almost unfairly convenient. One binary, one console, one bucket, done.&lt;/p&gt;

&lt;p&gt;But this app had a slightly different smell:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lots of generated media.&lt;/strong&gt; Images, audio, and possibly video. That means many files, many reads, and a lot of cache behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A need for public and internal paths.&lt;/strong&gt; The backend should talk to storage privately. The browser should see a clean HTTPS media domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A future where "single disk with object API" might stop being enough.&lt;/strong&gt; I did not need a giant cluster on day one, but I wanted a path that did not force another migration the moment storage became more serious.&lt;/p&gt;

&lt;p&gt;SeaweedFS looked attractive because it is not only trying to mimic S3. Its architecture has masters, volume servers, filers, and an S3 gateway. That is more moving parts, yes. But it also means the S3 API is not the whole product.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — filer:&lt;/strong&gt; in SeaweedFS, the filer gives you a directory/file abstraction and metadata layer. The actual bytes live on volume servers. The S3 API sits in front of that world so S3 clients can still PUT and GET objects.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The official SeaweedFS README talks a lot about fast small-file access, volume-id lookup, and using existing metadata stores for filer metadata. That is not something I cared about in the first hour. I cared about "does boto3 upload work?" But later, those design choices started making the system feel more powerful than MinIO for the kind of messy media workload I was dealing with.&lt;/p&gt;

&lt;h3&gt;
  
  
  MinIO vs SeaweedFS in my head
&lt;/h3&gt;

&lt;p&gt;This is not a benchmark. This is the practical decision table I wish I had while choosing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MinIO:
  best when      I want S3-compatible object storage quickly
  mental model   server -&amp;gt; bucket -&amp;gt; object
  setup feel     simple
  operations     easier to explain
  downside       feels narrower if I later need a bigger storage architecture

SeaweedFS:
  best when      I expect lots of files and want room to grow storage topology
  mental model   master -&amp;gt; filer -&amp;gt; volume -&amp;gt; optional S3 API
  setup feel     more nouns, more config, more thinking
  operations     more powerful but less beginner-friendly
  downside       I need to understand the pieces before debugging production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the app only needs "store some files and give me S3 URLs", MinIO is the clean answer.&lt;/p&gt;

&lt;p&gt;If the app is media-heavy and might eventually need multiple storage nodes, filer behavior, public/internal endpoints, and more control over how files are organized, SeaweedFS becomes interesting.&lt;/p&gt;

&lt;p&gt;I did not pick SeaweedFS because it was easier. I picked it because the future shape of the app looked less tiny than the first deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  The boto3 config was familiar
&lt;/h3&gt;

&lt;p&gt;The pleasant surprise: the app code still stayed S3-shaped.&lt;/p&gt;

&lt;p&gt;The client factory looked almost the same as MinIO. The important bits were &lt;code&gt;endpoint_url&lt;/code&gt;, SigV4, and path-style addressing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;botocore.config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_s3_client&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;endpoint_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_S3_ENDPOINT_URL&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS_S3_ENDPOINT_URL is not configured&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;signature_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3v4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;addressing_style&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_access_key_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_secret_access_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_REGION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — path-style addressing:&lt;/strong&gt; &lt;code&gt;https://storage.example.com/bucket/key&lt;/code&gt; instead of &lt;code&gt;https://bucket.storage.example.com/key&lt;/code&gt;. Self-hosted S3-compatible systems usually want path-style unless you have wildcard DNS and TLS set up for every bucket hostname.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Uploads stayed boring, which is exactly what I wanted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_to_s3_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s3_client&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s3_client&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s3_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_s3_client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_upload&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;s3_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload_fileobj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Fileobj&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;file_obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ExtraArgs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ContentType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_upload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;presigned_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generate_presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;presigned_url&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;asyncio.to_thread&lt;/code&gt; is doing real work. boto3 is blocking. FastAPI is async. If you call boto3 directly inside the request handler, one slow upload/download can block the event loop and make unrelated requests feel haunted. Offloading the blocking storage call into a thread keeps the async server breathing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The storage module boundary
&lt;/h3&gt;

&lt;p&gt;The smartest thing in this migration was not SeaweedFS. It was keeping storage behind one module.&lt;/p&gt;

&lt;p&gt;The rest of the app should not know whether the backend is AWS S3, MinIO, SeaweedFS, or a directory behind nginx. Most code should only need verbs like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upload_to_s3_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generate_presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;public_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generate_public_s3_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;delete_s3_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sounds boring, but boring boundaries save migrations.&lt;/p&gt;

&lt;p&gt;The module owns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;credentials
endpoint_url
public_url
bucket name
path-style addressing
presigned URL generation
blocking boto3 calls
Redis caching
ContentType
delete behavior
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The feature code owns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;what file is being uploaded
who is allowed to see it
what key/name it should have
what DB row points to it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When those boundaries blur, every storage migration becomes a treasure hunt. I prefer boring now. Boring is portable.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part SeaweedFS forced me to understand
&lt;/h3&gt;

&lt;p&gt;With MinIO, I had already learned that pre-signed URLs are fragile. The host is part of the SigV4 signature. Change the host after signing and you get &lt;code&gt;SignatureDoesNotMatch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;SeaweedFS made that lesson more explicit because I wanted two endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AWS_S3_ENDPOINT_URL=http://seaweed:8333
AWS_S3_PUBLIC_URL=https://media.example.com
AWS_BUCKET_NAME=app-media
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend container should use the internal endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://seaweed:8333
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser should use the public HTTPS endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://media.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first instinct was to generate the URL internally, then rewrite the host to the public domain. That is exactly the trap from the MinIO migration. It looks like a harmless string operation, but it breaks the signature.&lt;/p&gt;

&lt;p&gt;So I split the clients.&lt;/p&gt;

&lt;p&gt;One client is for backend storage operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_s3_client&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_S3_ENDPOINT_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_access_key_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_secret_access_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_REGION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signature_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3v4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;addressing_style&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Another client is specifically for pre-signed URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_s3_presign_client&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;endpoint_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_S3_PUBLIC_URL&lt;/span&gt;
        &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_S3_ENDPOINT_URL&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_access_key_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_secret_access_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_REGION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signature_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3v4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;addressing_style&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the clean version of what I learned the painful way:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sign the URL with the hostname the browser will actually request.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the browser requests &lt;code&gt;https://media.example.com/app-media/file.webp&lt;/code&gt;, the pre-signing client should also use &lt;code&gt;https://media.example.com&lt;/code&gt;. Not &lt;code&gt;http://seaweed:8333&lt;/code&gt;. Not an IP. Not a Docker service name. The public host.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why hostnames break signatures
&lt;/h3&gt;

&lt;p&gt;This is the thing I keep needing to re-explain to myself.&lt;/p&gt;

&lt;p&gt;A pre-signed URL is not just "a URL with a token". It is a signed representation of a specific HTTP request. The signature is calculated from pieces like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP method
path
query params
headers
host
expiry
secret key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if boto3 signs this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET http://seaweed:8333/app-media/image.webp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the browser actually requests this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET https://media.example.com/app-media/image.webp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SeaweedFS receives a request whose host does not match the signed host. The storage server recalculates the signature from the request it received, compares it to the signature in the URL, and says no.&lt;/p&gt;

&lt;p&gt;This is why the fix is not "replace the hostname in the string".&lt;/p&gt;

&lt;p&gt;The fix is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use internal endpoint for backend upload/download.
Use public endpoint for presigned URL generation.
Make nginx/proxy route public endpoint to the storage service without changing path/query.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the shape.&lt;/p&gt;

&lt;h3&gt;
  
  
  The public URL helper
&lt;/h3&gt;

&lt;p&gt;Not every file needs a pre-signed URL. Some generated assets can be public, and for those I wanted a fast path that did not sign anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_public_s3_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="n"&gt;bucket_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_BUCKET_NAME&lt;/span&gt;
    &lt;span class="n"&gt;encoded_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;public_endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_S3_PUBLIC_URL&lt;/span&gt;
        &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_S3_ENDPOINT_URL&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;public_endpoint&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bucket_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;encoded_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a security feature. This is the opposite: it assumes the object is public or the endpoint is allowed to serve it without a signature. But it matters because not all media in an app has the same privacy level.&lt;/p&gt;

&lt;p&gt;Private generated content gets pre-signed URLs.&lt;/p&gt;

&lt;p&gt;Public thumbnails, static generated samples, and assets meant to be indexed can use clean public URLs.&lt;/p&gt;

&lt;p&gt;One storage backend. Two access patterns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching pre-signed URLs without lying to yourself
&lt;/h3&gt;

&lt;p&gt;Pre-signing is cheap, but not free. Also, signing on every list endpoint is wasteful if you are rendering dozens of media items. So I cached pre-signed URLs in Redis, always with a TTL shorter than the actual expiry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="n"&gt;expires_in&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expires_in&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S3_PRESIGNED_EXPIRES&lt;/span&gt;

    &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_presigned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;

    &lt;span class="n"&gt;s3_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_s3_presign_client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bucket_name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_BUCKET_NAME&lt;/span&gt;

    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s3_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ClientMethod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ExpiresIn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;expires_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;cache_ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expires_in&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_presigned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cache_ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;expires_in - 60&lt;/code&gt; part is the boring bit that prevents edge-of-expiry bugs. You do not want to hand the frontend a URL that technically has three seconds left because Redis still had it. Shave a minute off and sleep better.&lt;/p&gt;

&lt;p&gt;I also learned from the MinIO migration that cache keys should be treated as infrastructure-coupled. If you change storage hostnames, old pre-signed URLs can survive in Redis and point at the wrong world. The fix is either clear the cache during migration or store enough metadata to reject stale URLs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The smoke tests I want before trusting storage
&lt;/h3&gt;

&lt;p&gt;I do not trust storage because config "looks right" anymore. I trust storage after a script puts bytes in and gets the same bytes out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;smoke_storage&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_s3_client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS_BUCKET_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;smoke/test.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello-storage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/plain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;

    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ClientMethod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ExpiresIn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I test the public URL path too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="s2"&gt;"https://media.example.com/app-media/smoke/test.txt"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if it is pre-signed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PRESIGNED_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure messages tell you where to look:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AccessDenied              credentials or bucket policy
NoSuchBucket              bucket was never created or wrong bucket env
NoSuchKey                 object key mismatch
SignatureDoesNotMatch     host/path/query changed after signing
Connection refused        endpoint/port/proxy problem
SSL error                 public URL/TLS/proxy certificate problem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This little table alone would have saved me hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  The production-ish environment shape
&lt;/h3&gt;

&lt;p&gt;The env vars that mattered were basically these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_BUCKET_NAME=app-media
AWS_REGION=us-east-1

# Internal service URL. Backend uses this for direct storage operations.
AWS_S3_ENDPOINT_URL=http://seaweed:8333

# Public browser URL. Presigned URLs should be generated against this host.
AWS_S3_PUBLIC_URL=https://media.example.com

S3_PRESIGNED_EXPIRES=3600
REDIS_URL=redis://redis:6379/0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The names still say &lt;code&gt;AWS_&lt;/code&gt;, which is a little funny because AWS is exactly what I was not using. I kept them because boto3 and old code already spoke that language. Sometimes consistency beats semantic purity.&lt;/p&gt;

&lt;p&gt;Future-me note: if public files are broken, inspect &lt;code&gt;AWS_S3_PUBLIC_URL&lt;/code&gt;. If backend uploads are broken, inspect &lt;code&gt;AWS_S3_ENDPOINT_URL&lt;/code&gt;. If pre-signed URLs generate but fail in browser, inspect the host in the generated URL before touching the upload code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why SeaweedFS felt harder
&lt;/h3&gt;

&lt;p&gt;SeaweedFS was harder because it has more nouns.&lt;/p&gt;

&lt;p&gt;MinIO gives you server, bucket, object.&lt;/p&gt;

&lt;p&gt;SeaweedFS gives you master, volume, filer, S3 gateway, bucket, collection, replication, filer metadata, public endpoint, internal endpoint, and sometimes IAM depending on how you deploy it.&lt;/p&gt;

&lt;p&gt;That does not make it bad. It makes it less fake-simple.&lt;/p&gt;

&lt;p&gt;With MinIO, I mostly thought about S3 compatibility.&lt;/p&gt;

&lt;p&gt;With SeaweedFS, I had to think about how the storage system itself was shaped. Where does metadata live? Which endpoint should the app use? Which endpoint should the browser use? What happens when I add another volume server? Am I using the S3 layer as the main interface, or do I eventually want filer-level access too?&lt;/p&gt;

&lt;p&gt;This is where SeaweedFS started feeling more powerful. It was not trying to be only a replacement for S3. It was giving me a storage architecture I could grow into.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — volume server:&lt;/strong&gt; the SeaweedFS component that stores actual file content. The master knows which volume has which file id. This is part of why SeaweedFS can make file lookup efficient: once you know the volume id, finding the bytes is direct and cacheable.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The cost of that power is operational complexity. You have to understand more before you can debug confidently. When something fails, "is the S3 API up?" is not the only question. You may need to ask whether the filer can talk to volumes, whether the public endpoint is signing correctly, whether the bucket exists, whether the credentials map to the right permissions, and whether your reverse proxy is preserving the host the signature expects.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I would check during an incident
&lt;/h3&gt;

&lt;p&gt;If media suddenly breaks, I would debug from the outside inward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Is the browser URL sane?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Does it use https?
Does it contain the expected bucket?
Does the hostname match AWS_S3_PUBLIC_URL?
Does it still point to an old MinIO/S3 host?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Does the public endpoint answer?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://media.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Can the backend talk to internal storage?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; http://seaweed:8333
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Can boto3 list buckets from inside the backend environment?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_s3_client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_buckets&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. Is Redis handing out stale URLs?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Clear presigned URL cache after endpoint changes.
Never trust old cached URLs after storage migration.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;6. Is nginx preserving path and query string?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pre-signed URLs need the exact path and query params. A proxy that drops query strings, normalizes paths, or redirects to a different host can break signatures.&lt;/p&gt;

&lt;p&gt;This debugging order matters because storage failures can look like frontend bugs. The image tag fails. The video player spins. The gallery looks empty. But the real bug might be four layers down in a signature host mismatch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Would I use it again?
&lt;/h3&gt;

&lt;p&gt;Yes, but not automatically.&lt;/p&gt;

&lt;p&gt;For a small app on one VM where the only requirement is "replace S3 quickly", I would still reach for MinIO first. It is simpler, friendlier, and easier to explain to the next developer.&lt;/p&gt;

&lt;p&gt;For a media-heavy app with lots of generated files, a future need for scaling storage horizontally, and a client who wants self-hosted infrastructure without buying into a commercial object-store subscription, I would seriously consider SeaweedFS again.&lt;/p&gt;

&lt;p&gt;It is lower-level in the best and worst ways.&lt;/p&gt;

&lt;p&gt;The best way: it gives you more control over the storage architecture than "one S3-compatible box with a console".&lt;/p&gt;

&lt;p&gt;The worst way: it makes you responsible for understanding that architecture.&lt;/p&gt;

&lt;p&gt;The thing I liked most is that my application code did not have to become SeaweedFS-specific. It still spoke S3 through boto3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;s3_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload_fileobj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Fileobj&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;file_obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ExtraArgs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ContentType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the trick I keep coming back to. The app should know as little as possible about the storage backend. Put the weirdness in one module. Make the rest of the code say "upload object", "generate URL", "delete object", and move on with its life.&lt;/p&gt;

&lt;p&gt;MinIO taught me that S3-compatible storage can save a migration.&lt;/p&gt;

&lt;p&gt;SeaweedFS taught me that S3 compatibility is just the front door. Sometimes the building behind it is much bigger.&lt;/p&gt;

</description>
      <category>seaweed</category>
      <category>s3</category>
      <category>aws</category>
      <category>linux</category>
    </item>
    <item>
      <title>The Client Said On-Prem, So I Learned Linux Deployments</title>
      <dc:creator>Niloy Nath</dc:creator>
      <pubDate>Fri, 10 Jul 2026 23:45:40 +0000</pubDate>
      <link>https://dev.to/piplustheta/the-client-said-on-prem-so-i-learned-linux-deployments-52k7</link>
      <guid>https://dev.to/piplustheta/the-client-said-on-prem-so-i-learned-linux-deployments-52k7</guid>
      <description>&lt;h3&gt;
  
  
  Why I wrote this
&lt;/h3&gt;

&lt;p&gt;After the storage migration, I ran into the next very normal sentence that secretly means "your weekend is gone":&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can we deploy this on our own Linux server?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not "can we deploy it to AWS". Not "can we use ECS". Not "please make a Terraform module and call it a day". The ask was two separate apps, two separate client-owned machines, and the same hard constraint underneath: keep it off managed AWS infrastructure.&lt;/p&gt;

&lt;p&gt;This changed the shape of the problem immediately. ECS was out. RDS was out. ALB was out. CloudWatch was out. All the boring managed pieces I usually lean on became my problem again: process restarts, logs, ports, database connectivity, Redis, TLS, health checks, static assets, long-running requests, file permissions, and the tiny Linux details that wait until 2 AM to become interesting.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — on-prem:&lt;/strong&gt; in this context, "on-prem" did not mean a polished datacenter with a platform team and runbooks. It meant Linux VMs controlled by the client, with their security rules, their access level, their firewall, their domain setup, and their appetite for giving me privileges. Which was, in one case, very little.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The funny part is that both deployments were basically the same product shape: a React/Vite frontend, a FastAPI backend, Postgres, Redis, media generation, payment/webhook endpoints, and admin routes. But the two servers allowed completely different deployment strategies.&lt;/p&gt;

&lt;p&gt;One gave me Docker. The other gave me a shell account and no sudo.&lt;/p&gt;

&lt;p&gt;Same app category. Completely different battle.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mental model I wish I had before starting
&lt;/h3&gt;

&lt;p&gt;Cloud deployment makes you think deployment is mainly "where do I push this image?" On-prem corrected me very quickly. Deployment is not one thing. It is a stack of small responsibilities that cloud services usually absorb for you.&lt;/p&gt;

&lt;p&gt;This is the checklist I now keep in my head:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runtime:&lt;/strong&gt; how does the app process start? &lt;code&gt;uvicorn&lt;/code&gt;, &lt;code&gt;gunicorn&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;serve&lt;/code&gt;, something else?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Process supervision:&lt;/strong&gt; if the process dies, who restarts it? Docker restart policy, systemd, PM2, or nobody?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Networking:&lt;/strong&gt; which port does the app bind to, and is that port private or public?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reverse proxy:&lt;/strong&gt; what receives public traffic and forwards it inward? Usually nginx.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TLS:&lt;/strong&gt; where does HTTPS terminate? nginx, a hosting panel, Cloudflare, or inside the app?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistence:&lt;/strong&gt; where do Postgres, Redis, uploaded media, and generated files live?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration:&lt;/strong&gt; where do secrets and environment variables come from?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability:&lt;/strong&gt; where do logs go, and how do I know the app is alive?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recovery:&lt;/strong&gt; after a reboot, what comes back automatically?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — process supervision:&lt;/strong&gt; a process supervisor is the thing that keeps your app process alive. It starts it, restarts it after crashes, and usually gives you logs. ECS does this in AWS. Docker can do it with &lt;code&gt;restart: unless-stopped&lt;/code&gt;. systemd does it at the OS level. PM2 can do it from a user account. A terminal tab does not do it. A terminal tab is a dare.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once I started thinking in those boxes, the two deployments stopped feeling like chaos and started feeling like different answers to the same questions.&lt;/p&gt;

&lt;p&gt;Docker deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;runtime              container CMD / entrypoint
process supervision  Docker restart policy
networking           Docker bridge network + exposed internal ports
reverse proxy        nginx container
TLS                  nginx/certbot
persistence          Docker volumes
logs                 docker compose logs
recovery             docker compose services restart on boot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No-sudo deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;runtime              uvicorn + static frontend server
process supervision  PM2
networking           loopback ports only
reverse proxy        whatever the host already provided
TLS                  outside my user account
persistence          existing DB/storage paths
logs                 pm2 logs
recovery             pm2 save, if startup integration exists
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second list is not pretty, but it is honest. And honest architecture is easier to debug than imaginary architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  The deployment I wanted: Docker, nginx, Postgres, Redis
&lt;/h3&gt;

&lt;p&gt;The first machine let me use Docker, so I built the thing the way I wish every small on-prem deployment could be built: containers for the app boundary, compose for orchestration, nginx at the edge, and health checks everywhere.&lt;/p&gt;

&lt;p&gt;The backend image was a fairly normal FastAPI production container. Build wheels in one stage, run a slim runtime in the second stage, drop privileges to a non-root user, then start Gunicorn with Uvicorn workers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.12-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /build&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    build-essential gcc g++ libpq-dev libffi-dev libssl-dev &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip setuptools wheel &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    pip wheel &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;--wheel-dir&lt;/span&gt; /wheels &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    pip wheel &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;--wheel-dir&lt;/span&gt; /wheels gunicorn uvicorn[standard]

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-slim&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PYTHONDONTWRITEBYTECODE=1 \&lt;/span&gt;
    PYTHONUNBUFFERED=1 \
    PORT=8000 \
    WEB_CONCURRENCY=4 \
    GUNICORN_TIMEOUT=300

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    libpq5 curl ca-certificates ffmpeg &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /wheels /wheels&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;--no-index&lt;/span&gt; &lt;span class="nt"&gt;--find-links&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/wheels /wheels/&lt;span class="k"&gt;*&lt;/span&gt;.whl &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /wheels

&lt;span class="k"&gt;RUN &lt;/span&gt;groupadd &lt;span class="nt"&gt;-r&lt;/span&gt; app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; useradd &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; app &lt;span class="nt"&gt;-u&lt;/span&gt; 1000 app
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=app:app ./app ./app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=app:app docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/docker-entrypoint.sh

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; app&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8000&lt;/span&gt;

&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=15s --timeout=3s --start-period=20s --retries=3 \&lt;/span&gt;
  CMD curl -f http://127.0.0.1:${PORT}/health || exit 1

&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/usr/local/bin/docker-entrypoint.sh"]&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["gunicorn", "app.main:app"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;ffmpeg&lt;/code&gt; line looks random until you remember media apps are never just "web apps". Someone will upload a video, the backend will need to inspect it, transcode it, or generate something from it, and suddenly your clean Python image needs system packages.&lt;/p&gt;

&lt;p&gt;The entrypoint did the part I usually want systemd or ECS to do for me: calculate worker count, set timeouts, and start the backend in a way that can survive real traffic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WEB_CONCURRENCY&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;CPU_COUNT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;nproc &lt;/span&gt;2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;1&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;WORKERS_PER_CORE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WORKERS_PER_CORE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  &lt;span class="nv"&gt;MAX_WORKERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MAX_WORKERS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;12&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  &lt;span class="nv"&gt;CALCULATED_WORKERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;CPU_COUNT &lt;span class="o"&gt;*&lt;/span&gt; WORKERS_PER_CORE &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CALCULATED_WORKERS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAX_WORKERS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;WEB_CONCURRENCY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$MAX_WORKERS&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nv"&gt;WEB_CONCURRENCY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$CALCULATED_WORKERS&lt;/span&gt;
  &lt;span class="k"&gt;fi
fi

&lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-k&lt;/span&gt; uvicorn.workers.UvicornWorker &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WEB_CONCURRENCY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="s2"&gt;"0.0.0.0:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;8000&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GUNICORN_TIMEOUT&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;300&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--graceful-timeout&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GUNICORN_GRACEFUL_TIMEOUT&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;30&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-requests&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GUNICORN_MAX_REQUESTS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;10000&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-requests-jitter&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GUNICORN_MAX_REQUESTS_JITTER&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;1000&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--worker-tmp-dir&lt;/span&gt; /dev/shm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--access-logfile&lt;/span&gt; - &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--error-logfile&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — Gunicorn + Uvicorn:&lt;/strong&gt; Uvicorn is the ASGI server that actually speaks async Python to FastAPI. Gunicorn is the process manager around it. Running &lt;code&gt;gunicorn -k uvicorn.workers.UvicornWorker&lt;/code&gt; gives you multiple worker processes, graceful restarts, timeouts, and process supervision inside the container.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Compose then became the map of the whole app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;expose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-fsS"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:8000/health"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;redis-server&lt;/span&gt;
      &lt;span class="s"&gt;--maxmemory 2gb&lt;/span&gt;
      &lt;span class="s"&gt;--maxmemory-policy allkeys-lru&lt;/span&gt;
      &lt;span class="s"&gt;--appendonly yes&lt;/span&gt;
      &lt;span class="s"&gt;--appendfsync everysec&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important thing here is not that compose is glamorous. It is not. The important thing is that the runtime is explicit. If the backend dies, restart it. If Postgres is not healthy, do not start the backend yet. If Redis goes away, make it obvious. If logs grow forever, rotate them. If the server reboots, bring everything back.&lt;/p&gt;

&lt;p&gt;Cloud platforms hide this machinery so well that you forget it exists. On a Linux box, it very much exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why each Docker piece existed
&lt;/h3&gt;

&lt;p&gt;This is the part I want future-me to reread before casually deleting something from a compose file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;backend&lt;/code&gt; existed for the API.&lt;/strong&gt; It should not publish a public port directly. It should expose &lt;code&gt;8000&lt;/code&gt; only to the Docker network and let nginx decide what the outside world can reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;db&lt;/code&gt; existed because the app needed Postgres close to it.&lt;/strong&gt; Managed RDS was not allowed, so Postgres became my responsibility. The volume mattered more than the container. Containers are replaceable; database files are not.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — Docker volume:&lt;/strong&gt; a Docker-managed persistent directory mounted into a container. If you delete and recreate the Postgres container but keep the volume, the database survives. If you delete the volume, congratulations, you have invented a restore drill.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;redis&lt;/code&gt; existed for cache, queues, rate-ish state, and pre-signed URL caching.&lt;/strong&gt; Redis is often treated as disposable until some part of the app quietly starts depending on it. I used append-only persistence because losing cache is fine, but losing every transient app state after a restart can become annoying fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;nginx&lt;/code&gt; existed because public traffic should not hit app servers directly.&lt;/strong&gt; nginx handles TLS, upload size, compression, static assets, proxy headers, admin route guards, and long timeouts. It is boring in the exact way production needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;certbot&lt;/code&gt; existed because certificates expire.&lt;/strong&gt; It is easy to get HTTPS working once. The real question is whether it will still work 91 days later when you are not thinking about it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — health check:&lt;/strong&gt; a command that tells the platform whether a service is alive. For an API, this is usually &lt;code&gt;curl /health&lt;/code&gt;. For Postgres, &lt;code&gt;pg_isready&lt;/code&gt;. For Redis, &lt;code&gt;redis-cli ping&lt;/code&gt;. Health checks are not decoration; they are how your deployment decides whether to route traffic, restart services, or wait before starting dependents.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The most useful Docker command was not &lt;code&gt;up&lt;/code&gt;. It was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it answered the question I actually had during deploy: "What does the machine think is running?"&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx became the load balancer I did not have
&lt;/h3&gt;

&lt;p&gt;Without an ALB, nginx became the front door: TLS termination, static files, API proxy, admin route protection, upload limits, long request timeouts, and health checks.&lt;/p&gt;

&lt;p&gt;The app had image and video generation paths, so default proxy timeouts were a trap. A "normal" 60 second timeout looks fine until a user kicks off a video workflow and nginx gives up while the backend is still doing exactly what it was asked to do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;worker_processes&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;worker_rlimit_nofile&lt;/span&gt; &lt;span class="mi"&gt;1000000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;events&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;worker_connections&lt;/span&gt; &lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;multi_accept&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;http&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;sendfile&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;tcp_nopush&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;tcp_nodelay&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;keepalive_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;client_header_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;client_body_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;send_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;200M&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;backend_upstream&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt; &lt;span class="s"&gt;fail_timeout=5s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt; &lt;span class="s"&gt;www.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/health&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend_upstream/health&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend_upstream/api/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_send_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — reverse proxy:&lt;/strong&gt; nginx is not running the app. It receives the public request, decides where it should go, forwards it to an internal service, and streams the response back. In cloud terms, it is doing a slice of what an ALB, CDN, TLS manager, and routing layer might normally do together.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This deployment was not painless, but it was legible. &lt;code&gt;docker compose ps&lt;/code&gt; showed the state. &lt;code&gt;docker compose logs -f backend&lt;/code&gt; showed the app. &lt;code&gt;docker compose restart backend&lt;/code&gt; did what it said. I could rebuild one service without touching the others.&lt;/p&gt;

&lt;p&gt;That is the good version of on-prem.&lt;/p&gt;

&lt;h3&gt;
  
  
  My Docker deploy runbook
&lt;/h3&gt;

&lt;p&gt;This is the version I wish I had written before doing it the first time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. SSH in and go to the repo&lt;/span&gt;
ssh app@example-server
&lt;span class="nb"&gt;cd&lt;/span&gt; /srv/app

&lt;span class="c"&gt;# 2. Pull code&lt;/span&gt;
git pull

&lt;span class="c"&gt;# 3. Check env before touching containers&lt;/span&gt;
&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; backend/.env
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"DATABASE_URL|REDIS_URL|FRONTEND_URL|BACKEND_URL"&lt;/span&gt; backend/.env

&lt;span class="c"&gt;# 4. Build images&lt;/span&gt;
docker compose build backend frontend

&lt;span class="c"&gt;# 5. Start dependencies first if needed&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; db redis
docker compose ps

&lt;span class="c"&gt;# 6. Apply migrations if the app has them&lt;/span&gt;
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; backend alembic upgrade &lt;span class="nb"&gt;head&lt;/span&gt;

&lt;span class="c"&gt;# 7. Start app + edge&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; backend frontend nginx

&lt;span class="c"&gt;# 8. Watch logs while hitting the site&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; backend nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not memorizing these exact commands. It is the order: get the code, confirm config, build, bring up stateful dependencies, migrate, start app services, then watch logs during real requests.&lt;/p&gt;

&lt;p&gt;If I skip the config check, I eventually pay for it in the logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The preflight checks I now do
&lt;/h3&gt;

&lt;p&gt;Before calling any on-prem deploy "done", I now check boring things out loud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;
free &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;span class="nb"&gt;uptime
&lt;/span&gt;docker system &lt;span class="nb"&gt;df
&lt;/span&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — &lt;code&gt;df -h&lt;/code&gt;:&lt;/strong&gt; shows disk usage. Disk-full bugs are some of the least glamorous production bugs. Postgres cannot write, Docker cannot pull layers, nginx cannot write logs, and suddenly everything looks broken in unrelated ways.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then app checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://127.0.0.1:8000/health
curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://example.com/health
curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://example.com/api/v1/bootstrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then user-flow checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;home page loads
login works
admin route behaves correctly
one media upload/generation works
one payment/webhook route is reachable
logs are not screaming
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sounds obvious. It is only obvious after you have deployed something that looked healthy until the first real user clicked the one route you did not test.&lt;/p&gt;

&lt;h3&gt;
  
  
  The deployment I got next: no sudo
&lt;/h3&gt;

&lt;p&gt;The second server looked similar from far away and completely different from the shell.&lt;/p&gt;

&lt;p&gt;No Docker.&lt;/p&gt;

&lt;p&gt;No systemd unit files.&lt;/p&gt;

&lt;p&gt;No nginx edits.&lt;/p&gt;

&lt;p&gt;No installing packages into &lt;code&gt;/usr/bin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No "just open this port".&lt;/p&gt;

&lt;p&gt;I had a user account, a project directory, Node, Python, and enough permissions to run processes as myself. Which sounds bad until you realize that a web app is, at the end of the day, a few long-running processes and some files. If I could not make the machine into a platform, I could make a tiny platform inside my home directory.&lt;/p&gt;

&lt;p&gt;That meant PM2.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — PM2:&lt;/strong&gt; PM2 is a Node.js process manager, but it can run any executable. It is not a replacement for Docker or systemd, but when you only have user-level permissions, it gives you process naming, restarts, logs, environment variables, and startup resurrection if the host has been configured for it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The frontend was a Vite app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vite --port 5174"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsc -b &amp;amp;&amp;amp; vite build"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vite preview"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For production, I did not want to run &lt;code&gt;vite dev&lt;/code&gt;. That is a dev server. So the pattern became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/app/frontend
npm ci
npm run build
npx serve &lt;span class="nt"&gt;-s&lt;/span&gt; dist &lt;span class="nt"&gt;-l&lt;/span&gt; 127.0.0.1:5174
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend was FastAPI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/app/backend
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;.&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
uvicorn app.main:app &lt;span class="nt"&gt;--host&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;--port&lt;/span&gt; 8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running those commands manually proves the app can start. It does not make a deployment. A deployment has to survive SSH disconnects, crashes, restarts, and "wait, which terminal did I run that in?"&lt;/p&gt;

&lt;p&gt;So PM2 became the wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;apps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/home/app/app/backend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.venv/bin/uvicorn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app.main:app --host 127.0.0.1 --port 8000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;interpreter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;ENVIRONMENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;PYTHONUNBUFFERED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;max_memory_restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;900M&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-web&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/home/app/app/frontend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;npx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;serve -s dist -l 127.0.0.1:5174&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;interpreter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then deploys became boring enough to repeat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git pull

&lt;span class="nb"&gt;cd &lt;/span&gt;backend
&lt;span class="nb"&gt;.&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="nb"&gt;cd&lt;/span&gt; ../frontend
npm ci
npm run build

&lt;span class="nb"&gt;cd&lt;/span&gt; ..
pm2 startOrReload ecosystem.config.cjs &lt;span class="nt"&gt;--update-env&lt;/span&gt;
pm2 save
pm2 status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not as clean as Docker. Not as correct as systemd. But it worked inside the permissions I actually had, which is the only deployment environment that matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  PM2 terms I keep forgetting
&lt;/h3&gt;

&lt;p&gt;I keep having to relearn PM2 because I do not use it every week, so here is the tiny glossary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Process name:&lt;/strong&gt; the human label, like &lt;code&gt;app-api&lt;/code&gt;. Use names. Do not rely on process IDs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cwd&lt;/code&gt;:&lt;/strong&gt; the working directory. This matters because relative imports, &lt;code&gt;.env&lt;/code&gt; files, and frontend &lt;code&gt;dist&lt;/code&gt; paths often assume a current directory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;script&lt;/code&gt;:&lt;/strong&gt; the executable PM2 runs. For Python, this can be &lt;code&gt;.venv/bin/uvicorn&lt;/code&gt;. For frontend static serving, it can be &lt;code&gt;npx&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;args&lt;/code&gt;:&lt;/strong&gt; everything after the command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;interpreter: "none"&lt;/code&gt;:&lt;/strong&gt; tells PM2 not to wrap the command in Node. Without this, PM2 may try to treat non-JS commands like JavaScript and you get nonsense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--update-env&lt;/code&gt;:&lt;/strong&gt; tells PM2 to reload environment variables when restarting. Without this, you can change &lt;code&gt;.env&lt;/code&gt; or shell vars and PM2 may keep the old world alive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;pm2 save&lt;/code&gt;:&lt;/strong&gt; writes the current process list so it can be resurrected later. If the host has PM2 startup configured, this is what makes your current app list survive reboot.&lt;/p&gt;

&lt;p&gt;The commands I actually use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 status
pm2 logs app-api &lt;span class="nt"&gt;--lines&lt;/span&gt; 100
pm2 restart app-api &lt;span class="nt"&gt;--update-env&lt;/span&gt;
pm2 restart app-web &lt;span class="nt"&gt;--update-env&lt;/span&gt;
pm2 delete app-api
pm2 save
pm2 monit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — &lt;code&gt;pm2 monit&lt;/code&gt;:&lt;/strong&gt; a terminal dashboard showing CPU, memory, restarts, and logs for PM2 processes. It is not observability in the fancy sense, but when you have no CloudWatch, it is a small lantern.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The no-sudo deploy runbook
&lt;/h3&gt;

&lt;p&gt;This is the version for "I have a shell account and I need to be useful."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. SSH in&lt;/span&gt;
ssh app@example-server
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/app

&lt;span class="c"&gt;# 2. Pull latest code&lt;/span&gt;
git pull

&lt;span class="c"&gt;# 3. Backend dependencies&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;backend
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;.&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="c"&gt;# 4. Smoke start backend before PM2&lt;/span&gt;
uvicorn app.main:app &lt;span class="nt"&gt;--host&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;--port&lt;/span&gt; 8000
&lt;span class="c"&gt;# Ctrl-C if it starts cleanly&lt;/span&gt;

&lt;span class="c"&gt;# 5. Frontend build&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../frontend
npm ci
&lt;span class="nv"&gt;VITE_API_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.example.com/api/v1 npm run build

&lt;span class="c"&gt;# 6. Reload managed processes&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ..
pm2 startOrReload ecosystem.config.cjs &lt;span class="nt"&gt;--update-env&lt;/span&gt;
pm2 save
pm2 status

&lt;span class="c"&gt;# 7. Warm and verify&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://127.0.0.1:8000/health
pm2 logs app-api &lt;span class="nt"&gt;--lines&lt;/span&gt; 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key move is starting the backend manually once before letting PM2 supervise it. If the app cannot start in the foreground, PM2 will not fix it. PM2 will just restart the same broken command very enthusiastically.&lt;/p&gt;

&lt;h3&gt;
  
  
  The weird bugs were all boundaries
&lt;/h3&gt;

&lt;p&gt;The code was rarely the hard part. The hard part was boundaries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The frontend needed the right API base URL at build time.&lt;/strong&gt; With Vite, environment variables are baked into the build. Change &lt;code&gt;VITE_API_BASE_URL&lt;/code&gt; after &lt;code&gt;npm run build&lt;/code&gt;, and the already-built JS does not care. You need to rebuild.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VITE_API_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.example.com/api/v1 npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The backend needed to bind privately.&lt;/strong&gt; If nginx or the hosting panel is the public entrypoint, the app should not casually bind to &lt;code&gt;0.0.0.0&lt;/code&gt; unless you mean to expose it. On the PM2 server I preferred loopback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uvicorn app.main:app &lt;span class="nt"&gt;--host&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;--port&lt;/span&gt; 8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Warmup mattered.&lt;/strong&gt; FastAPI startup was intentionally lightweight, but the first real request still had to pay for DB connections, Redis connections, config cache, GeoIP reader load, and whatever else had been lazily initialized. So I kept a tiny post-deploy warmup script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;http&lt;/span&gt;://127.0.0.1:8000&lt;span class="k"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4 5&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"warmup attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"HTTP %{http_code} - %{time_total}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASE_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/bootstrap"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  sleep &lt;/span&gt;0.2
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not magic. It just moves the first-request pain from an unlucky user to me, immediately after deploy, while I am still watching logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logs had to be part of the workflow.&lt;/strong&gt; In ECS I would check CloudWatch. Here it was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 logs app-api
pm2 logs app-web
pm2 monit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; backend
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; nginx
docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I learned to never call a deploy successful until I had tailed logs through one login, one API call, one media request, and one admin route. Otherwise you have not deployed the app. You have deployed optimism.&lt;/p&gt;

&lt;h3&gt;
  
  
  Things I would not forget next time
&lt;/h3&gt;

&lt;p&gt;I am writing this section mostly for myself, because this is exactly the kind of operational detail I forget once the incident is over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not expose app ports publicly unless you mean it.&lt;/strong&gt; Bind internal services to &lt;code&gt;127.0.0.1&lt;/code&gt; or Docker-only networks. Public traffic should go through nginx or the host's reverse proxy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not run Vite dev in production.&lt;/strong&gt; &lt;code&gt;vite --host&lt;/code&gt; feels convenient and then you remember it is a development server. Build static assets and serve &lt;code&gt;dist&lt;/code&gt;, or run a real SSR server if the app has one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not treat &lt;code&gt;.env&lt;/code&gt; as a mystery box.&lt;/strong&gt; Print the non-secret shape of the environment before deploy: which DB host, which Redis URL, which frontend URL, which backend URL. Never print actual secrets into logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not assume reboot recovery.&lt;/strong&gt; Test or at least inspect it. Docker restart policies help. PM2 needs &lt;code&gt;pm2 save&lt;/code&gt; and usually startup integration. A process currently running is not the same as a process that will come back after a reboot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not ignore upload size and timeout defaults.&lt;/strong&gt; Media apps need larger &lt;code&gt;client_max_body_size&lt;/code&gt; and longer proxy timeouts. Otherwise the backend can be healthy while nginx kills the request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not call it done before testing the boring flows.&lt;/strong&gt; Login, admin, media upload, payment/webhook, cache-dependent endpoint, one mobile page.&lt;/p&gt;

&lt;p&gt;I used to think this kind of checklist was overkill. Then I had to debug production on someone else's Linux box, with someone else's permissions, while trying to remember which layer owned TLS. I am very pro-checklist now. Character development, but make it &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Would I do it this way again?
&lt;/h3&gt;

&lt;p&gt;If sudo is available, I would use Docker again. No contest.&lt;/p&gt;

&lt;p&gt;Docker gave me a repeatable runtime, named services, network isolation, health checks, restart policies, and clean logs. The compose file became living documentation. Six months later, someone can read it and understand what the machine is supposed to be running.&lt;/p&gt;

&lt;p&gt;If sudo is not available, I would still use PM2 again, but with a very clear mental label: this is a survival deployment, not a platform.&lt;/p&gt;

&lt;p&gt;PM2 will keep a process alive. It will not give you container isolation. It will not own system ports. It will not configure nginx. It will not install Postgres. It will not solve backups. But if the real constraint is "you may run userland processes and nothing else", PM2 is a perfectly respectable little raft.&lt;/p&gt;

&lt;p&gt;The lesson I took from both deployments is not "Docker good, PM2 bad". It is that deployment strategy is mostly permission strategy.&lt;/p&gt;

&lt;p&gt;Cloud gives you APIs. Docker gives you a local platform. Sudo gives you the machine. No sudo gives you a home directory and your manners.&lt;/p&gt;

&lt;p&gt;And sometimes that is still enough.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>infrastructure</category>
      <category>infrastructureascode</category>
      <category>docker</category>
    </item>
    <item>
      <title>The Client Said No AWS, So I Learned MinIO</title>
      <dc:creator>Niloy Nath</dc:creator>
      <pubDate>Fri, 10 Jul 2026 23:43:59 +0000</pubDate>
      <link>https://dev.to/piplustheta/the-client-said-no-aws-so-i-learned-minio-4d9a</link>
      <guid>https://dev.to/piplustheta/the-client-said-no-aws-so-i-learned-minio-4d9a</guid>
      <description>&lt;h3&gt;
  
  
  Why I wrote this
&lt;/h3&gt;

&lt;p&gt;Until this project, "object storage" and "S3" were the same word in my head. Bucket, IAM keys, boto3, done. I had never once thought about what happens underneath, because AWS's whole pitch is that I don't have to.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — object storage:&lt;/strong&gt; unlike a filesystem (folders, files, paths) or a database (rows, queries), object storage stores blobs of data ("objects") in flat containers called buckets, addressed by a string key like &lt;code&gt;users/42/avatar.png&lt;/code&gt;. No real directories — the slashes in keys are just a naming convention. It's the standard way to store user uploads, media, backups, and anything too big or too numerous for a database.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then a client told me the app had to run entirely on their own Linux servers. Not "prefer self-hosted", not "AWS but in a specific region" — no AWS account at all, no managed anything, their VMs, their disks. The app was a FastAPI backend generating a lot of media (images, audio, video), and every one of those files was going through S3 with pre-signed URLs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — pre-signed URL:&lt;/strong&gt; a temporary URL that grants access to a single private object without exposing your credentials. The server signs it using its secret key, embedding an expiry time and a cryptographic signature into the query string. Anyone holding the URL can fetch that one object until it expires — no login needed. This is how you serve private media directly from storage without routing every byte through your backend.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I had a weekend to figure out what replaces S3 when S3 is not allowed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I looked at
&lt;/h3&gt;

&lt;p&gt;I didn't do a deep bake-off, I'll be honest. I had a deadline. But I did spend an evening reading before committing, and this is roughly how each option died:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ceph&lt;/strong&gt; came up first because everyone says Ceph. It's the heavyweight distributed storage system — it can expose block storage, a filesystem, and an S3-compatible API (through a component called RADOS Gateway) all from the same cluster. Then I read the deployment docs and closed the tab. Ceph assumes a cluster of machines and someone whose job is to babysit it. I had two VMs and my job was shipping the app. Deploying Ceph here would have been solving a problem I didn't have while creating several I couldn't afford.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SeaweedFS&lt;/strong&gt; looked genuinely interesting — it's designed around the Facebook Haystack paper, optimized for billions of small files, and the benchmarks are impressive. But its S3 API is a gateway bolted onto its own volume/filer architecture, and I didn't want to learn a new mental model (volumes? filers? masters?) just to serve media files for one app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Just the filesystem + nginx&lt;/strong&gt; was the tempting lazy option. Save uploads to a directory, let nginx serve them. But I'd lose pre-signed URLs — which the app leaned on heavily for private media — plus multipart uploads for large videos, and every line of existing storage code. What looks like the simple option was actually the biggest rewrite.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — multipart upload:&lt;/strong&gt; the S3 mechanism for uploading large files in parallel chunks, with the ability to retry individual chunks on failure. For anything above ~100MB it's the difference between "upload failed at 98%, start over" and "retry chunk 47". boto3's &lt;code&gt;upload_fileobj&lt;/code&gt; uses it automatically above a size threshold — another thing you get for free by staying S3-compatible.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;MinIO&lt;/strong&gt; won for one boring reason: it speaks the actual S3 API. Same boto3, same calls, same v4 signatures. It ships as a single Go binary (or one Docker image), stores objects on a plain disk path, and includes a web console where the client could see their files. My storage code wouldn't know the difference. When the deciding factor is "how much of my working code do I get to keep", S3-compatible wins by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  The migration was one config value (almost)
&lt;/h3&gt;

&lt;p&gt;All my storage code already went through one helper module — every S3 call in the codebase funnels through &lt;code&gt;storage.py&lt;/code&gt;. That turned out to be the luckiest architectural accident of the project. The only real change was giving boto3 an &lt;code&gt;endpoint_url&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;botocore.config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlparse&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_s3_client&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_S3_ENDPOINT_URL&lt;/span&gt;      &lt;span class="c1"&gt;# e.g. http://minio:9000
&lt;/span&gt;    &lt;span class="n"&gt;access_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;
    &lt;span class="n"&gt;secret_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;

    &lt;span class="n"&gt;use_ssl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;use_ssl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheme&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# boto3 dislikes trailing slashes
&lt;/span&gt;
    &lt;span class="n"&gt;botocore_cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;signature_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3v4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;addressing_style&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;standard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_access_key_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;access_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;aws_secret_access_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;secret_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;botocore_cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;use_ssl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;use_ssl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is that &lt;code&gt;endpoint_url&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt; when you're on real AWS (boto3 falls back to the standard AWS endpoints), and points at your MinIO host otherwise. Same function, both worlds.&lt;/p&gt;

&lt;p&gt;Two things in that config I only learned by hitting errors:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;signature_version="s3v4"&lt;/code&gt;&lt;/strong&gt; — MinIO only supports AWS Signature Version 4. Ancient code or old SDK defaults sometimes still use v2. If your requests come back with signature errors against MinIO, check this first.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — Signature v4 (SigV4):&lt;/strong&gt; the algorithm AWS uses to authenticate API requests. Instead of sending your secret key, the client computes an HMAC signature over the request (method, host, path, headers, timestamp) and sends that. The server recomputes it and compares. This is also exactly what's inside a pre-signed URL — which becomes very important later in this post.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;addressing_style: "path"&lt;/code&gt;&lt;/strong&gt; — AWS defaults to &lt;em&gt;virtual-hosted style&lt;/em&gt;, where the bucket name becomes part of the hostname: &lt;code&gt;my-bucket.s3.amazonaws.com/key&lt;/code&gt;. For that to work on a self-hosted box, you'd need wildcard DNS (&lt;code&gt;*.minio.example.com&lt;/code&gt;) resolving to your server, which nobody sets up for a single-node deployment. &lt;em&gt;Path style&lt;/em&gt; puts the bucket in the path instead — &lt;code&gt;minio:9000/my-bucket/key&lt;/code&gt; — and just works with one hostname.&lt;/p&gt;

&lt;p&gt;Everything downstream of the client factory stayed untouched, because it's all just S3 calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3_client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;36000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;client_method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;put_object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s3_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_presigned_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ClientMethod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;client_method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bucket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ExpiresIn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;expires_in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_fileobj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3_client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fileobj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;extra_args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ContentType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;s3_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload_fileobj&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Fileobj&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fileobj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ExtraArgs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;extra_args&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;ContentType&lt;/code&gt; on upload is one of those details that doesn't matter until it does: it's what makes the browser render an image inline instead of downloading it as a mystery file, and what makes &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tags work at all.&lt;/p&gt;

&lt;p&gt;I also wrote a dumb smoke test — create client, list buckets, put an object, read it back, delete it — and I now run it against every environment before believing anything works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_s3_client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Buckets:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_buckets&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Buckets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;

    &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app-media&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;smoke_test/test.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello-minio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/plain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;smoke test OK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time I ran it, it failed — wrong bucket name in the env file. Which is exactly why it exists. Thirty seconds at the bottom of the stack instead of an hour of debugging failed uploads three layers up. Unlike S3, MinIO doesn't auto-create anything: the bucket has to exist before the first &lt;code&gt;put_object&lt;/code&gt;, either created through the web console (port 9001) or MinIO's &lt;code&gt;mc&lt;/code&gt; CLI.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that actually cost me a day
&lt;/h3&gt;

&lt;p&gt;Here's what nobody warns you about. MinIO was listening on &lt;code&gt;http://&amp;lt;server-ip&amp;gt;:9000&lt;/code&gt;. The frontend was served over HTTPS. Every pre-signed URL the backend handed out was an &lt;code&gt;http://&lt;/code&gt; URL pointing at a raw IP — and browsers refuse to load HTTP content from an HTTPS page. On top of that, I was leaking the storage server's IP address to every single user in every media URL.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — mixed content:&lt;/strong&gt; when an HTTPS page tries to load a resource (image, video, script) over plain HTTP. Browsers block it — silently for media, loudly in the console — because it undermines the security guarantee of the padlock. There is no setting to allow it for your users; you have to serve everything over HTTPS.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My first instinct was: easy, just rewrite the URL's host to the real HTTPS domain before returning it to the frontend. I did that. Everything broke with &lt;code&gt;SignatureDoesNotMatch&lt;/code&gt;, and it took me embarrassingly long to understand why: the signature in a pre-signed URL is computed over the request &lt;em&gt;including the host and path&lt;/em&gt; (that's the SigV4 from earlier). Change any of it and the signature no longer matches, and the storage server correctly rejects the request. The URL is a sealed envelope — you can't edit the address on it without breaking the seal.&lt;/p&gt;

&lt;p&gt;The fix that worked: don't modify the signed URL at all. Wrap it. The backend rewrites MinIO URLs into a proxy URL on its own HTTPS domain, with the original pre-signed URL — signature intact — tucked into a query parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rewrite_minio_url_to_https&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Can't touch the presigned URL itself — signature covers host+path.
&lt;/span&gt;    &lt;span class="c1"&gt;# So we encode the whole thing and let /storage/proxy fetch it for us.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
    &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quote_plus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BACKEND_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/storage/proxy?url=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;encoded&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;quote_plus&lt;/code&gt; matters here: the pre-signed URL contains its own query string (&lt;code&gt;?X-Amz-Signature=...&amp;amp;X-Amz-Expires=...&lt;/code&gt;), and if you don't URL-encode it before embedding it in another URL, everything after the first &lt;code&gt;&amp;amp;&lt;/code&gt; gets interpreted as parameters of the &lt;em&gt;outer&lt;/em&gt; URL and the signature gets truncated. Encode the whole thing, decode it on the other side.&lt;/p&gt;

&lt;p&gt;The proxy endpoint decodes the URL, fetches it server-side, and streams the response back to the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.api_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/storage/proxy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HEAD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;storage_proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;decoded_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unquote_plus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoded_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Without this check you've built an open proxy that will
&lt;/span&gt;    &lt;span class="c1"&gt;# happily fetch any URL on the internet on your server's behalf.
&lt;/span&gt;    &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AWS_S3_ENDPOINT_URL&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;netloc&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;netloc&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forbidden host&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;forward_headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*/*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;range_hdr&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;forward_headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;range_hdr&lt;/span&gt;    &lt;span class="c1"&gt;# video seeking = 206 responses
&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;30.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;upstream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoded_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;forward_headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;passthrough&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache-control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accept-ranges&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;passthrough&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there that cost real debugging time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The host allowlist.&lt;/strong&gt; A proxy endpoint that fetches arbitrary URLs from a query parameter is an SSRF hole — someone can point it at your internal services (&lt;code&gt;?url=http://localhost:8000/admin&lt;/code&gt;) and your server will fetch it with its own network access. Checking the target against the one configured MinIO endpoint closes that.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — SSRF (Server-Side Request Forgery):&lt;/strong&gt; an attack where you trick a server into making HTTP requests on your behalf, typically to internal systems the attacker can't reach directly. Any endpoint that accepts a URL and fetches it is a candidate. Always allowlist the destinations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Forwarding the &lt;code&gt;Range&lt;/code&gt; header.&lt;/strong&gt; Without it, video worked — but seeking didn't. The player would start from zero every time you dragged the timeline. Browsers implement video seeking by requesting byte ranges (&lt;code&gt;Range: bytes=1048576-&lt;/code&gt;), and the server answers with &lt;code&gt;206 Partial Content&lt;/code&gt; and just that slice. If the proxy swallows the header, the upstream sends the whole file from byte zero and the player gives up on seeking. Same story for &lt;code&gt;accept-ranges&lt;/code&gt; and &lt;code&gt;content-range&lt;/code&gt; on the way back — the player needs those headers to know that seeking is even possible.&lt;/p&gt;

&lt;p&gt;Is this proxy the "right" architecture? No. The proper fix is putting MinIO itself behind nginx with a real TLS certificate on its own subdomain, so pre-signed URLs are HTTPS from birth and browsers hit storage directly with no extra hop. But the cert situation on the client's box was not getting sorted that week, and the proxy shipped that day. It also quietly fixed CORS and stopped leaking the server IP, so I've made peace with it. If traffic ever justifies it, the nginx route is documented and waiting.&lt;/p&gt;

&lt;p&gt;One more bug from the same migration, because it's a good one: I cache pre-signed URLs in Redis so I'm not re-signing on every request (signing is cheap, but at scale, every avoided call counts). After switching endpoints, users started getting broken media — the cache was still full of perfectly valid-looking &lt;code&gt;amazonaws.com&lt;/code&gt; URLs pointing at buckets that no longer served anything. Cached URLs that outlive the infrastructure they point to. The fix is validating cache entries against the current endpoint before trusting them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_presigned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amazonaws.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_endpoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;redis_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presigned:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s3_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# re-sign fresh
&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;General lesson filed away: whenever infrastructure changes, ask what's still sitting in caches pointing at the old world.&lt;/p&gt;

&lt;h3&gt;
  
  
  Would I pick it again?
&lt;/h3&gt;

&lt;p&gt;For this project, yes, without hesitation. The constraint was hard (data stays on their servers, full stop), the codebase was already S3-shaped, and the scale was one beefy VM — not a use case that needs a distributed storage story. The app also serves a lot of media, and not paying S3 egress on every video view is a real number, not a theoretical one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note — egress:&lt;/strong&gt; cloud-speak for data leaving the provider's network, i.e. bytes served to your users. AWS charges roughly $0.09/GB for it. Uploads (ingress) are free; it's the downloads that get you. A media-heavy app's egress bill scales directly with its popularity, which is a rude thing to discover during a good month.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But I want to be honest about what I gave up, because I felt it. S3 quietly gives you eleven nines of durability — it stores every object redundantly across multiple physical facilities, and you never think about it. A single MinIO node is exactly as durable as the disk it sits on. Backups, monitoring, disk-full alerts at 2 AM — those are mine now. (MinIO does support erasure coding — spreading data plus parity blocks across multiple drives so it can survive drive failures, RAID-like but at the object level — but that needs multiple drives, which this deployment didn't have. Nightly volume backups were the honest answer at this scale.)&lt;/p&gt;

&lt;p&gt;There's also no ecosystem. No lifecycle rules quietly moving old files to cheaper storage, no bucket events triggering functions, no CDN a checkbox away. MinIO can approximate some of it (bucket notifications, ILM policies), but you're assembling it yourself. And MinIO is AGPL-licensed — fine for running it as a service inside a deployment like this, but if you're embedding it in software you distribute, read the license before your lawyer does.&lt;/p&gt;

&lt;p&gt;If your constraint is "not AWS" rather than "not cloud", look at Cloudflare R2 or Backblaze B2 first — both speak the S3 API, both are drastically cheaper on egress (R2's is literally free), and neither makes you the on-call storage admin. If you're drowning in billions of tiny files, SeaweedFS is built for exactly that. If you have real multi-node durability requirements and a team to match, that's what Ceph is for. And if a directory of files behind nginx genuinely covers your needs, do that and don't feel bad about it.&lt;/p&gt;

&lt;p&gt;The thing I actually took away from this isn't about MinIO at all. It's that the thin &lt;code&gt;storage.py&lt;/code&gt; wrapper I wrote out of habit — one module, every S3 call goes through it — turned a forced infrastructure swap into a config change plus one proxy endpoint. I didn't design it for portability. But I'll pretend I did in the retro.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>tutorial</category>
      <category>fastapi</category>
      <category>python</category>
    </item>
  </channel>
</rss>
