<?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: Derrick Amenuve</title>
    <description>The latest articles on DEV Community by Derrick Amenuve (@godsloveady).</description>
    <link>https://dev.to/godsloveady</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F375077%2F020c97a3-3c2d-4d0e-9528-c8a18738cabb.jpeg</url>
      <title>DEV Community: Derrick Amenuve</title>
      <link>https://dev.to/godsloveady</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/godsloveady"/>
    <language>en</language>
    <item>
      <title>Storing Kamal secrets in AWS Secrets Manager and deploying to a cheap Hetzner VPS</title>
      <dc:creator>Derrick Amenuve</dc:creator>
      <pubDate>Sat, 23 May 2026 11:55:55 +0000</pubDate>
      <link>https://dev.to/godsloveady/storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner-vps-18b0</link>
      <guid>https://dev.to/godsloveady/storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner-vps-18b0</guid>
      <description>&lt;p&gt;I ran into a problem with Kamal. My &lt;code&gt;.kamal/secrets&lt;/code&gt; file was full of API keys sitting in plaintext on my laptop. Anyone with access could read them all.&lt;/p&gt;

&lt;p&gt;TLDR; Use &lt;a href="https://kamal-deploy.org/docs/commands/secrets/" rel="noopener noreferrer"&gt;Kamal&lt;/a&gt; with &lt;a href="https://aws.amazon.com/secrets-manager/" rel="noopener noreferrer"&gt;AWS Secrets Manager&lt;/a&gt; and deploy to a &lt;a href="https://hetzner.cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; VPS. No plaintext secrets, cheap hosting, compliance happy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Kamal is great for deploying apps. But by default secrets are in a plaintext file. For SOC 2 and GDPR that does not work. You need a managed store. I went with AWS Secrets Manager.&lt;/p&gt;

&lt;p&gt;But then I hit another issue. The &lt;code&gt;kamal secrets fetch --adapter aws_secrets_manager&lt;/code&gt; command with &lt;code&gt;--from&lt;/code&gt; expects each key to be its own AWS secret. If you store everything as one JSON blob (like I did), you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR (RuntimeError): myapp/production/secrets//DEEPGRAM_API_KEY: Secrets Manager can't find the specified secret.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Hetzner VPS
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://hetzner.cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; CAX series starts at around 4 euro a month. I use the CX22 with 2 vCPUs and 4GB RAM. Enough for production.&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;# On your Hetzner server&lt;/span&gt;
apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io

&lt;span class="c"&gt;# Copy your SSH key so Kamal can connect&lt;/span&gt;
ssh-copy-id root@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your &lt;code&gt;config/deploy.yml&lt;/code&gt;:&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;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;yourdomain.com&lt;/span&gt;

&lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ssl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;yourdomain.com&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;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/health/ready&lt;/span&gt;

&lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-docker-user&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;KAMAL_REGISTRY_PASSWORD&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need a &lt;a href="https://hub.docker.com/" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt; account and a personal access token for &lt;code&gt;KAMAL_REGISTRY_PASSWORD&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create the secret in AWS
&lt;/h2&gt;

&lt;p&gt;In the &lt;a href="https://aws.amazon.com/secrets-manager/" rel="noopener noreferrer"&gt;AWS Secrets Manager&lt;/a&gt; Console:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to Secrets Manager &amp;gt; Store a new secret&lt;/li&gt;
&lt;li&gt;Select "Other type of secret"&lt;/li&gt;
&lt;li&gt;Switch to plaintext tab and paste your JSON
&lt;/li&gt;
&lt;/ol&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;"DEEPGRAM_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_deepgram_key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ASSEMBLY_AI_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_assemblyai_key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"REDIS_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"redis://:password@your-redis:6379"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"KAMAL_REGISTRY_PASSWORD"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_docker_token"&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;ol&gt;
&lt;li&gt;Name it &lt;code&gt;myapp/production/secrets&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Click Store&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pick a region close to your server. If your Hetzner box is in Germany, use &lt;code&gt;eu-central-1&lt;/code&gt; (Frankfurt). Keeps latency low and GDPR happy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: IAM user for your laptop
&lt;/h2&gt;

&lt;p&gt;Your laptop needs permission to read the secret during deploy.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to IAM &amp;gt; Users &amp;gt; Create user&lt;/li&gt;
&lt;li&gt;Name it &lt;code&gt;kamal-deploy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Uncheck console access (CLI only)&lt;/li&gt;
&lt;li&gt;Create a group called &lt;code&gt;secrets-manager&lt;/code&gt; with the SecretsManagerReadWrite policy&lt;/li&gt;
&lt;li&gt;Add an inline policy for batch reading:
&lt;/li&gt;
&lt;/ol&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&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="s2"&gt;"secretsmanager:GetSecretValue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"secretsmanager:DescribeSecret"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"secretsmanager:BatchGetSecretValue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"secretsmanager:ListSecrets"&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;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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="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;ol&gt;
&lt;li&gt;Add your user to the group&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;IAM policies can take a minute to propagate. If it fails at first, wait 30 seconds and try again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Configure AWS CLI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure
&lt;span class="c"&gt;# AWS Access Key ID: paste from IAM user&lt;/span&gt;
&lt;span class="c"&gt;# AWS Secret Access Key: paste&lt;/span&gt;
&lt;span class="c"&gt;# Default region name: eu-central-1&lt;/span&gt;
&lt;span class="c"&gt;# Default output format: json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the start of your JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Format your .kamal/secrets file
&lt;/h2&gt;

&lt;p&gt;This is where I got stuck. The &lt;code&gt;--from&lt;/code&gt; flag wants one AWS secret per key. Having 20 separate secrets is annoying. Check the &lt;a href="https://kamal-deploy.org/docs/commands/secrets/" rel="noopener noreferrer"&gt;Kamal secrets docs&lt;/a&gt; for more on this.&lt;/p&gt;

&lt;p&gt;Instead I use the AWS CLI with Python extraction. Each line is self contained:&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;# AWS Secrets Manager: myapp/production/secrets (eu-central-1)&lt;/span&gt;
&lt;span class="nv"&gt;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;ASSEMBLY_AI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['ASSEMBLY_AI_API_KEY'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;REDIS_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['REDIS_URL'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;KAMAL_REGISTRY_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line fetches the full JSON and extracts one key. Kamal evaluates each line in its own subshell so there are no shared variables between lines. This works.&lt;/p&gt;

&lt;p&gt;You can also use &lt;code&gt;jq&lt;/code&gt; if you prefer:&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;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.DEEPGRAM_API_KEY'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: Deploy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kamal deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kamal fetches secrets from AWS during deploy and injects them into your container. No plaintext file ever touches the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production and staging
&lt;/h2&gt;

&lt;p&gt;I use a different AWS secret per environment. Both pull from AWS no plaintext anywhere.&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;# .kamal/secrets  (used by kamal deploy)&lt;/span&gt;
&lt;span class="nv"&gt;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;KAMAL_REGISTRY_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# .kamal/secrets.staging  (used by kamal deploy -d staging)&lt;/span&gt;
&lt;span class="nv"&gt;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/staging/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;KAMAL_REGISTRY_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/staging/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the secret name changes between files. &lt;code&gt;myapp/production/secrets&lt;/code&gt; for production, &lt;code&gt;myapp/staging/secrets&lt;/code&gt; for staging. Run &lt;code&gt;kamal deploy -d staging&lt;/code&gt; and Kamal reads from the staging file.&lt;/p&gt;

&lt;p&gt;Both secrets live in AWS. No staging credentials in plaintext either. This matters for SOC 2 because auditors check every environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Done
&lt;/h2&gt;

&lt;p&gt;No more secrets in plaintext. SOC 2 and GDPR requirements met. Hetzner bill stays under 5 euro a month.&lt;/p&gt;

&lt;p&gt;Big thanks to the &lt;a href="https://aws.amazon.com/secrets-manager/" rel="noopener noreferrer"&gt;AWS docs team&lt;/a&gt;, the &lt;a href="https://kamal-deploy.org/docs/commands/secrets/" rel="noopener noreferrer"&gt;Kamal maintainers&lt;/a&gt;, and &lt;a href="https://hetzner.cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; for keeping hosting affordable. Hope this saves you the same headaches I ran into. Now back to building.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>aws</category>
      <category>docker</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Storing Kamal secrets in AWS Secrets Manager and deploying to a cheap Hetzner VPS</title>
      <dc:creator>Derrick Amenuve</dc:creator>
      <pubDate>Sat, 23 May 2026 11:48:51 +0000</pubDate>
      <link>https://dev.to/godsloveady/storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner-vps-2262</link>
      <guid>https://dev.to/godsloveady/storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner-vps-2262</guid>
      <description>&lt;p&gt;I ran into a problem with Kamal. My &lt;code&gt;.kamal/secrets&lt;/code&gt; file was full of API keys sitting in plaintext on my laptop. Anyone with access could read them all.&lt;/p&gt;

&lt;p&gt;TLDR; Use &lt;a href="https://kamal-deploy.org/docs/commands/secrets/" rel="noopener noreferrer"&gt;Kamal&lt;/a&gt; with &lt;a href="https://aws.amazon.com/secrets-manager/" rel="noopener noreferrer"&gt;AWS Secrets Manager&lt;/a&gt; and deploy to a &lt;a href="https://hetzner.cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; VPS. No plaintext secrets, cheap hosting, compliance happy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Kamal is great for deploying apps. But by default secrets are in a plaintext file. For SOC 2 and GDPR that does not work. You need a managed store. I went with AWS Secrets Manager.&lt;/p&gt;

&lt;p&gt;But then I hit another issue. The &lt;code&gt;kamal secrets fetch --adapter aws_secrets_manager&lt;/code&gt; command with &lt;code&gt;--from&lt;/code&gt; expects each key to be its own AWS secret. If you store everything as one JSON blob (like I did), you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR (RuntimeError): myapp/production/secrets//DEEPGRAM_API_KEY: Secrets Manager can't find the specified secret.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Hetzner VPS
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://hetzner.cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; CAX series starts at around 4 euro a month. I use the CX22 with 2 vCPUs and 4GB RAM. Enough for production.&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;# On your Hetzner server&lt;/span&gt;
apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io

&lt;span class="c"&gt;# Copy your SSH key so Kamal can connect&lt;/span&gt;
ssh-copy-id root@your-server-ip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your &lt;code&gt;config/deploy.yml&lt;/code&gt;:&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;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;runtime.yourdomain.com&lt;/span&gt;

&lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ssl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;runtime.yourdomain.com&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;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/health/ready&lt;/span&gt;

&lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-docker-user&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;KAMAL_REGISTRY_PASSWORD&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need a &lt;a href="https://hub.docker.com/" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt; account and a personal access token for &lt;code&gt;KAMAL_REGISTRY_PASSWORD&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create the secret in AWS
&lt;/h2&gt;

&lt;p&gt;In the &lt;a href="https://aws.amazon.com/secrets-manager/" rel="noopener noreferrer"&gt;AWS Secrets Manager&lt;/a&gt; Console:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to Secrets Manager &amp;gt; Store a new secret&lt;/li&gt;
&lt;li&gt;Select "Other type of secret"&lt;/li&gt;
&lt;li&gt;Switch to plaintext tab and paste your JSON
&lt;/li&gt;
&lt;/ol&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;"DEEPGRAM_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_deepgram_key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ASSEMBLY_AI_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_assemblyai_key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"REDIS_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"redis://:password@your-redis:6379"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"KAMAL_REGISTRY_PASSWORD"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_docker_token"&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;ol&gt;
&lt;li&gt;Name it &lt;code&gt;myapp/production/secrets&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Click Store&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pick a region close to your server. If your Hetzner box is in Germany, use &lt;code&gt;eu-central-1&lt;/code&gt; (Frankfurt). Keeps latency low and GDPR happy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: IAM user for your laptop
&lt;/h2&gt;

&lt;p&gt;Your laptop needs permission to read the secret during deploy.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to IAM &amp;gt; Users &amp;gt; Create user&lt;/li&gt;
&lt;li&gt;Name it &lt;code&gt;kamal-deploy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Uncheck console access (CLI only)&lt;/li&gt;
&lt;li&gt;Create a group called &lt;code&gt;secrets-manager&lt;/code&gt; with the SecretsManagerReadWrite policy&lt;/li&gt;
&lt;li&gt;Add an inline policy for batch reading:
&lt;/li&gt;
&lt;/ol&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&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="s2"&gt;"secretsmanager:GetSecretValue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"secretsmanager:DescribeSecret"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"secretsmanager:BatchGetSecretValue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"secretsmanager:ListSecrets"&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;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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="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;ol&gt;
&lt;li&gt;Add your user to the group&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;IAM policies can take a minute to propagate. If it fails at first, wait 30 seconds and try again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Configure AWS CLI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure
&lt;span class="c"&gt;# AWS Access Key ID: paste from IAM user&lt;/span&gt;
&lt;span class="c"&gt;# AWS Secret Access Key: paste&lt;/span&gt;
&lt;span class="c"&gt;# Default region name: eu-central-1&lt;/span&gt;
&lt;span class="c"&gt;# Default output format: json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the start of your JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Format your .kamal/secrets file
&lt;/h2&gt;

&lt;p&gt;This is where I got stuck. The &lt;code&gt;--from&lt;/code&gt; flag wants one AWS secret per key. Having 20 separate secrets is annoying. Check the &lt;a href="https://kamal-deploy.org/docs/commands/secrets/" rel="noopener noreferrer"&gt;Kamal secrets docs&lt;/a&gt; for more on this.&lt;/p&gt;

&lt;p&gt;Instead I use the AWS CLI with Python extraction. Each line is self contained:&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;# AWS Secrets Manager: myapp/production/secrets (eu-central-1)&lt;/span&gt;
&lt;span class="nv"&gt;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;ASSEMBLY_AI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['ASSEMBLY_AI_API_KEY'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;REDIS_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['REDIS_URL'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;KAMAL_REGISTRY_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line fetches the full JSON and extracts one key. Kamal evaluates each line in its own subshell so there are no shared variables between lines. This works.&lt;/p&gt;

&lt;p&gt;You can also use &lt;code&gt;jq&lt;/code&gt; if you prefer:&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;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.DEEPGRAM_API_KEY'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: Deploy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kamal deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kamal fetches secrets from AWS during deploy and injects them into your container. No plaintext file ever touches the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production and staging
&lt;/h2&gt;

&lt;p&gt;I use a different AWS secret per environment. Both pull from AWS no plaintext anywhere.&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;# .kamal/secrets  (used by kamal deploy)&lt;/span&gt;
&lt;span class="nv"&gt;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;KAMAL_REGISTRY_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/production/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# .kamal/secrets.staging  (used by kamal deploy -d staging)&lt;/span&gt;
&lt;span class="nv"&gt;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/staging/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;KAMAL_REGISTRY_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; myapp/staging/secrets &lt;span class="nt"&gt;--query&lt;/span&gt; SecretString &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the secret name changes between files. &lt;code&gt;myapp/production/secrets&lt;/code&gt; for production, &lt;code&gt;myapp/staging/secrets&lt;/code&gt; for staging. Run &lt;code&gt;kamal deploy -d staging&lt;/code&gt; and Kamal reads from the staging file.&lt;/p&gt;

&lt;p&gt;Both secrets live in AWS. No staging credentials in plaintext either. This matters for SOC 2 because auditors check every environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Done
&lt;/h2&gt;

&lt;p&gt;No more secrets in plaintext. SOC 2 and GDPR requirements met. Hetzner bill stays under 5 euro a month.&lt;/p&gt;

&lt;p&gt;Big thanks to the &lt;a href="https://aws.amazon.com/secrets-manager/" rel="noopener noreferrer"&gt;AWS docs team&lt;/a&gt;, the &lt;a href="https://kamal-deploy.org/docs/commands/secrets/" rel="noopener noreferrer"&gt;Kamal maintainers&lt;/a&gt;, and &lt;a href="https://hetzner.cloud/" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt; for keeping hosting affordable. Hope this saves you the same headaches I ran into. Now back to building.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>kamal</category>
      <category>devops</category>
      <category>deployment</category>
    </item>
    <item>
      <title>Resolving ActiveStorage URL Generation Between Rails and Avo admin gem</title>
      <dc:creator>Derrick Amenuve</dc:creator>
      <pubDate>Tue, 15 Aug 2023 19:16:49 +0000</pubDate>
      <link>https://dev.to/godsloveady/resolving-activestorage-url-generation-between-rails-and-avo-admin-gem-21ib</link>
      <guid>https://dev.to/godsloveady/resolving-activestorage-url-generation-between-rails-and-avo-admin-gem-21ib</guid>
      <description>&lt;p&gt;I've been working on my project &lt;a href="https://modatongue.com" rel="noopener noreferrer"&gt;Modatongue&lt;/a&gt; and hit a roadblock with Active Storage. Images were displaying fine in lessons or my main rails application, but not in my Avo admin! 😭&lt;/p&gt;

&lt;p&gt;TLDR;&lt;br&gt;
Just change line 3 of your &lt;strong&gt;app/views/active_storage/_blobs.html.erb&lt;/strong&gt; to use main_app.url_ &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6q2z1wuocdognzljfxzc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6q2z1wuocdognzljfxzc.png" alt=" " width="800" height="211"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fguewcri78y2r8lrw81c5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fguewcri78y2r8lrw81c5.png" alt=" " width="799" height="211"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My Battle with Active Storage (and How I Won!)&lt;/strong&gt;&lt;br&gt;
Like I mentioned earlier the Images were displaying fine in my lessons or inside the main rails app, but not in my &lt;a href="https://avohq.io/" rel="noopener noreferrer"&gt;Avo&lt;/a&gt; admin or when I logged into my admin interface! 😭&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;figure class="attachment attachment--&amp;lt;%= blob.representable? ? "preview" : "file" %&amp;gt; attachment--&amp;lt;%= blob.filename.extension %&amp;gt;"&amp;gt;
  &amp;lt;% if blob.representable? %&amp;gt;
    &amp;lt;%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %&amp;gt;
  &amp;lt;% end %&amp;gt;

  &amp;lt;figcaption class="attachment__caption"&amp;gt;
    &amp;lt;% if caption = blob.try(:caption) %&amp;gt;
      &amp;lt;%= caption %&amp;gt;
    &amp;lt;% else %&amp;gt;
      &amp;lt;span class="attachment__name"&amp;gt;&amp;lt;%= blob.filename %&amp;gt;&amp;lt;/span&amp;gt;
      &amp;lt;span class="attachment__size"&amp;gt;&amp;lt;%= number_to_human_size blob.byte_size %&amp;gt;&amp;lt;/span&amp;gt;
    &amp;lt;% end %&amp;gt;
  &amp;lt;/figcaption&amp;gt;
&amp;lt;/figure&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The above code for my blob partial(which is likely going to be your default when you install active storage). This works in Rails views, but not Avo which expects blob objects to respond to to_model. Avo admin cried undefined method 'to_model'. Ugh, why?! 🤯&lt;/p&gt;

&lt;p&gt;After a lot of trial and error, I realized Avo uses different helpers. So I tried bypassing them directly with:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;%= image_tag main_app.url_for(blob) %&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;so now my blob partial will look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;figure class="attachment attachment--&amp;lt;%= blob.representable? ? "preview" : "file" %&amp;gt; attachment--&amp;lt;%= blob.filename.extension %&amp;gt;"&amp;gt;
  &amp;lt;% if blob.representable? %&amp;gt;
    &amp;lt;%= image_tag main_app.url_for(blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ])) %&amp;gt;
  &amp;lt;% end %&amp;gt;

  &amp;lt;figcaption class="attachment__caption"&amp;gt;
    &amp;lt;% if caption = blob.try(:caption) %&amp;gt;
      &amp;lt;%= caption %&amp;gt;
    &amp;lt;% else %&amp;gt;
      &amp;lt;span class="attachment__name"&amp;gt;&amp;lt;%= blob.filename %&amp;gt;&amp;lt;/span&amp;gt;
      &amp;lt;span class="attachment__size"&amp;gt;&amp;lt;%= number_to_human_size blob.byte_size %&amp;gt;&amp;lt;/span&amp;gt;
    &amp;lt;% end %&amp;gt;
  &amp;lt;/figcaption&amp;gt;
&amp;lt;/figure&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Solution&lt;/strong&gt;&lt;br&gt;
The key was to bypass both helpers and generate the URL directly from Rails.&lt;/p&gt;

&lt;p&gt;This works because main_app references the root Rails application context:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;%= image_tag main_app.url_for(blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ])) %&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Now url_for generates the URL correctly for any ActiveStorage attachment, working identically in both Rails and Avo views.&lt;br&gt;
By leveraging the core Rails URL generation, a single rendering approach can work consistently across any frameworks layered on top of Rails. This keeps view code DRY and avoids compatibility issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eureka! It worked 🎉&lt;/strong&gt;&lt;br&gt;
Big props to the chatbots who helped clue me in - ChatGPT, Poe and especially Bard who provided the final hint. What a rollercoaster ride but so satisfying to crack it myself! 🙌&lt;/p&gt;

&lt;p&gt;I hope this helps you and feel free to say hi on &lt;a href="https://twitter.com/GodsloveADY" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;! Now back to building Modatongue.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>activestorage</category>
      <category>avo</category>
      <category>gem</category>
    </item>
    <item>
      <title>There is not only one right way</title>
      <dc:creator>Derrick Amenuve</dc:creator>
      <pubDate>Wed, 12 May 2021 09:32:32 +0000</pubDate>
      <link>https://dev.to/godsloveady/there-is-not-only-one-right-way-4dg6</link>
      <guid>https://dev.to/godsloveady/there-is-not-only-one-right-way-4dg6</guid>
      <description>&lt;p&gt;Many times when we want to work on design projects we normally think of following the best practices UX design process. This is a worrying subject for almost every designer especially beginners, we tend to ask ourselves if the flow or pattern we use is the best practice when it comes to UX design. &lt;/p&gt;

&lt;p&gt;I have come to understand that there isn't a one-way process when it comes to designing websites, web apps or mobile apps.  Your process will highly depend on the type of product you’re designing. Every project requires different approaches; the approach to designing a website for an alumni network of an institution or your alma mater differs from the way we design a cryptocurrency app(it's the new thing being talked about😀), for example.&lt;/p&gt;

&lt;p&gt;I think what we should really focus on as designers are to apply the concept of &lt;a href="https://www.interaction-design.org/literature/article/5-stages-in-the-design-thinking-process" rel="noopener noreferrer"&gt;Design Thinking&lt;/a&gt; and the principles of &lt;a href="https://www.toptal.com/designers/ui/principles-of-design" rel="noopener noreferrer"&gt;principles of design&lt;/a&gt;. The design thinking process has five stages in it: empathize, define, ideate, prototype, and test&lt;br&gt;
This should be great for any kind of project ✌🏽.&lt;/p&gt;

&lt;p&gt;My next post will be a brief on the process I used when designing a website for my Alumni network. Stay tuned, mia ga do go🤝 (meaning we will meet again, translated from Ewe- a Ghanaian language)&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>design</category>
    </item>
  </channel>
</rss>
