Tools: Complete Guide to Storing Kamal secrets in AWS Secrets Manager and deploying to a cheap Hetzner VPS

Tools: Complete Guide to Storing Kamal secrets in AWS Secrets Manager and deploying to a cheap Hetzner VPS

Step 1: Hetzner VPS

Step 2: Create the secret in AWS

Step 3: IAM user for your laptop

Step 4: Configure AWS CLI

Step 5: Format your .kamal/secrets file

Step 6: Deploy

Production and staging I ran into a problem with Kamal. My .kamal/secrets file was full of API keys sitting in plaintext on my laptop. Anyone with access could read them all. TLDR; Use Kamal with AWS Secrets Manager and deploy to a Hetzner VPS. No plaintext secrets, cheap hosting, compliance happy. 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. But then I hit another issue. The kamal secrets fetch --adapter aws_secrets_manager command with --from expects each key to be its own AWS secret. If you store everything as one JSON blob (like I did), you get: Hetzner CAX series starts at around 4 euro a month. I use the CX22 with 2 vCPUs and 4GB RAM. Enough for production. Your config/deploy.yml: You need a Docker Hub account and a personal access token for KAMAL_REGISTRY_PASSWORD. In the AWS Secrets Manager Console: Pick a region close to your server. If your Hetzner box is in Germany, use eu-central-1 (Frankfurt). Keeps latency low and GDPR happy. Your laptop needs permission to read the secret during deploy. IAM policies can take a minute to propagate. If it fails at first, wait 30 seconds and try again. You should see the start of your JSON. This is where I got stuck. The --from flag wants one AWS secret per key. Having 20 separate secrets is annoying. Check the Kamal secrets docs for more on this. Instead I use the AWS CLI with Python extraction. Each line is self contained: 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. You can also use jq if you prefer: Kamal fetches secrets from AWS during deploy and injects them into your container. No plaintext file ever touches the server. I use a different AWS secret per environment. Both pull from AWS no plaintext anywhere. Only the secret name changes between files. myapp/production/secrets for production, myapp/staging/secrets for staging. Run kamal deploy -d staging and Kamal reads from the staging file. Both secrets live in AWS. No staging credentials in plaintext either. This matters for SOC 2 because auditors check every environment. No more secrets in plaintext. SOC 2 and GDPR requirements met. Hetzner bill stays under 5 euro a month. Big thanks to the AWS docs team, the Kamal maintainers, and Hetzner for keeping hosting affordable. Hope this saves you the same headaches I ran into. Now back to building. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

ERROR (RuntimeError): myapp/production/secrets//DEEPGRAM_API_KEY: Secrets Manager can't find the specified secret. ERROR (RuntimeError): myapp/production/secrets//DEEPGRAM_API_KEY: Secrets Manager can't find the specified secret. ERROR (RuntimeError): myapp/production/secrets//DEEPGRAM_API_KEY: Secrets Manager can't find the specified secret. # On your Hetzner server apt update && apt install -y docker.io # Copy your SSH key so Kamal can connect ssh-copy-id root@your-server-ip # On your Hetzner server apt update && apt install -y docker.io # Copy your SSH key so Kamal can connect ssh-copy-id root@your-server-ip # On your Hetzner server apt update && apt install -y docker.io # Copy your SSH key so Kamal can connect ssh-copy-id root@your-server-ip servers: web: hosts: - yourdomain.com proxy: ssl: true hosts: - yourdomain.com healthcheck: path: /health/ready registry: server: docker.io username: your-docker-user password: - KAMAL_REGISTRY_PASSWORD servers: web: hosts: - yourdomain.com proxy: ssl: true hosts: - yourdomain.com healthcheck: path: /health/ready registry: server: docker.io username: your-docker-user password: - KAMAL_REGISTRY_PASSWORD servers: web: hosts: - yourdomain.com proxy: ssl: true hosts: - yourdomain.com healthcheck: path: /health/ready registry: server: docker.io username: your-docker-user password: - KAMAL_REGISTRY_PASSWORD { "DEEPGRAM_API_KEY": "your_deepgram_key", "ASSEMBLY_AI_API_KEY": "your_assemblyai_key", "REDIS_URL": "redis://:password@your-redis:6379", "KAMAL_REGISTRY_PASSWORD": "your_docker_token" } { "DEEPGRAM_API_KEY": "your_deepgram_key", "ASSEMBLY_AI_API_KEY": "your_assemblyai_key", "REDIS_URL": "redis://:password@your-redis:6379", "KAMAL_REGISTRY_PASSWORD": "your_docker_token" } { "DEEPGRAM_API_KEY": "your_deepgram_key", "ASSEMBLY_AI_API_KEY": "your_assemblyai_key", "REDIS_URL": "redis://:password@your-redis:6379", "KAMAL_REGISTRY_PASSWORD": "your_docker_token" } { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret", "secretsmanager:BatchGetSecretValue", "secretsmanager:ListSecrets" ], "Resource": "*" } ] } { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret", "secretsmanager:BatchGetSecretValue", "secretsmanager:ListSecrets" ], "Resource": "*" } ] } { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret", "secretsmanager:BatchGetSecretValue", "secretsmanager:ListSecrets" ], "Resource": "*" } ] } aws configure # AWS Access Key ID: paste from IAM user # AWS Secret Access Key: paste # Default region name: eu-central-1 # Default output format: json aws configure # AWS Access Key ID: paste from IAM user # AWS Secret Access Key: paste # Default region name: eu-central-1 # Default output format: json aws configure # AWS Access Key ID: paste from IAM user # AWS Secret Access Key: paste # Default region name: eu-central-1 # Default output format: json aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text | head -c 50 aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text | head -c 50 aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text | head -c 50 # AWS Secrets Manager: myapp/production/secrets (eu-central-1) DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") ASSEMBLY_AI_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['ASSEMBLY_AI_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") REDIS_URL=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['REDIS_URL'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") # AWS Secrets Manager: myapp/production/secrets (eu-central-1) DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") ASSEMBLY_AI_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['ASSEMBLY_AI_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") REDIS_URL=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['REDIS_URL'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") # AWS Secrets Manager: myapp/production/secrets (eu-central-1) DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") ASSEMBLY_AI_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['ASSEMBLY_AI_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") REDIS_URL=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['REDIS_URL'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") DEEPGRAM_API_KEY=$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text | jq -r '.DEEPGRAM_API_KEY') DEEPGRAM_API_KEY=$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text | jq -r '.DEEPGRAM_API_KEY') DEEPGRAM_API_KEY=$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text | jq -r '.DEEPGRAM_API_KEY') kamal deploy kamal deploy kamal deploy # .kamal/secrets (used by kamal deploy) DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") # .kamal/secrets.staging (used by kamal deploy -d staging) DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/staging/secrets --query SecretString --output text)") KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/staging/secrets --query SecretString --output text)") # .kamal/secrets (used by kamal deploy) DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") # .kamal/secrets.staging (used by kamal deploy -d staging) DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/staging/secrets --query SecretString --output text)") KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/staging/secrets --query SecretString --output text)") # .kamal/secrets (used by kamal deploy) DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text)") # .kamal/secrets.staging (used by kamal deploy -d staging) DEEPGRAM_API_KEY=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['DEEPGRAM_API_KEY'])" "$(aws secretsmanager get-secret-value --secret-id myapp/staging/secrets --query SecretString --output text)") KAMAL_REGISTRY_PASSWORD=$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['KAMAL_REGISTRY_PASSWORD'])" "$(aws secretsmanager get-secret-value --secret-id myapp/staging/secrets --query SecretString --output text)") - Go to Secrets Manager > Store a new secret - Select "Other type of secret" - Switch to plaintext tab and paste your JSON - Name it myapp/production/secrets - Click Store - Go to IAM > Users > Create user - Name it kamal-deploy - Uncheck console access (CLI only) - Create a group called secrets-manager with the SecretsManagerReadWrite policy - Add an inline policy for batch reading: - Add your user to the group