{"slug": "storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner", "title": "Storing Kamal secrets in AWS Secrets Manager and deploying to a cheap Hetzner VPS", "summary": "This article describes a solution for securely managing application secrets when using Kamal for deployment. The author explains how to store secrets in AWS Secrets Manager as a single JSON blob and retrieve them using Python or jq commands, rather than creating separate secrets for each key. The deployment targets a low-cost Hetzner VPS (starting at €4/month), with instructions for configuring Docker, Kamal, and IAM permissions.", "body_md": "I ran into a problem with Kamal. My `.kamal/secrets`\n\nfile was full of API keys sitting in plaintext on my laptop. Anyone with access could read them all.\n\nTLDR; Use [Kamal](https://kamal-deploy.org/docs/commands/secrets/) with [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) and deploy to a [Hetzner](https://hetzner.cloud/) VPS. No plaintext secrets, cheap hosting, compliance happy.\n\n**The problem**\n\nKamal 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.\n\nBut then I hit another issue. The `kamal secrets fetch --adapter aws_secrets_manager`\n\ncommand with `--from`\n\nexpects each key to be its own AWS secret. If you store everything as one JSON blob (like I did), you get:\n\n```\nERROR (RuntimeError): myapp/production/secrets//DEEPGRAM_API_KEY: Secrets Manager can't find the specified secret.\n```\n\n## Step 1: Hetzner VPS\n\n[Hetzner](https://hetzner.cloud/) CAX series starts at around 4 euro a month. I use the CX22 with 2 vCPUs and 4GB RAM. Enough for production.\n\n```\n# On your Hetzner server\napt update && apt install -y docker.io\n\n# Copy your SSH key so Kamal can connect\nssh-copy-id root@your-server-ip\n```\n\nYour `config/deploy.yml`\n\n:\n\n```\nservers:\n  web:\n    hosts:\n      - runtime.yourdomain.com\n\nproxy:\n  ssl: true\n  hosts:\n    - runtime.yourdomain.com\n  healthcheck:\n    path: /health/ready\n\nregistry:\n  server: docker.io\n  username: your-docker-user\n  password:\n    - KAMAL_REGISTRY_PASSWORD\n```\n\nYou need a [Docker Hub](https://hub.docker.com/) account and a personal access token for `KAMAL_REGISTRY_PASSWORD`\n\n.\n\n## Step 2: Create the secret in AWS\n\nIn the [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) Console:\n\n- Go to Secrets Manager > Store a new secret\n- Select \"Other type of secret\"\n- Switch to plaintext tab and paste your JSON\n\n```\n{\n  \"DEEPGRAM_API_KEY\": \"your_deepgram_key\",\n  \"ASSEMBLY_AI_API_KEY\": \"your_assemblyai_key\",\n  \"REDIS_URL\": \"redis://:password@your-redis:6379\",\n  \"KAMAL_REGISTRY_PASSWORD\": \"your_docker_token\"\n}\n```\n\n- Name it\n`myapp/production/secrets`\n\n- Click Store\n\nPick a region close to your server. If your Hetzner box is in Germany, use `eu-central-1`\n\n(Frankfurt). Keeps latency low and GDPR happy.\n\n## Step 3: IAM user for your laptop\n\nYour laptop needs permission to read the secret during deploy.\n\n- Go to IAM > Users > Create user\n- Name it\n`kamal-deploy`\n\n- Uncheck console access (CLI only)\n- Create a group called\n`secrets-manager`\n\nwith the SecretsManagerReadWrite policy - Add an inline policy for batch reading:\n\n```\n{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"secretsmanager:GetSecretValue\",\n        \"secretsmanager:DescribeSecret\",\n        \"secretsmanager:BatchGetSecretValue\",\n        \"secretsmanager:ListSecrets\"\n      ],\n      \"Resource\": \"*\"\n    }\n  ]\n}\n```\n\n- Add your user to the group\n\nIAM policies can take a minute to propagate. If it fails at first, wait 30 seconds and try again.\n\n## Step 4: Configure AWS CLI\n\n```\naws configure\n# AWS Access Key ID: paste from IAM user\n# AWS Secret Access Key: paste\n# Default region name: eu-central-1\n# Default output format: json\n```\n\nTest it:\n\n```\naws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text | head -c 50\n```\n\nYou should see the start of your JSON.\n\n## Step 5: Format your .kamal/secrets file\n\nThis is where I got stuck. The `--from`\n\nflag wants one AWS secret per key. Having 20 separate secrets is annoying. Check the [Kamal secrets docs](https://kamal-deploy.org/docs/commands/secrets/) for more on this.\n\nInstead I use the AWS CLI with Python extraction. Each line is self contained:\n\n```\n# AWS Secrets Manager: myapp/production/secrets (eu-central-1)\nDEEPGRAM_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)\")\nASSEMBLY_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)\")\nREDIS_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)\")\nKAMAL_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)\")\n```\n\nEach 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.\n\nYou can also use `jq`\n\nif you prefer:\n\n```\nDEEPGRAM_API_KEY=$(aws secretsmanager get-secret-value --secret-id myapp/production/secrets --query SecretString --output text | jq -r '.DEEPGRAM_API_KEY')\n```\n\n## Step 6: Deploy\n\n```\nkamal deploy\n```\n\nKamal fetches secrets from AWS during deploy and injects them into your container. No plaintext file ever touches the server.\n\n## Production and staging\n\nI use a different AWS secret per environment. Both pull from AWS no plaintext anywhere.\n\n``` python\n# .kamal/secrets  (used by kamal deploy)\nDEEPGRAM_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)\")\nKAMAL_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)\")\n\n# .kamal/secrets.staging  (used by kamal deploy -d staging)\nDEEPGRAM_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)\")\nKAMAL_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)\")\n```\n\nOnly the secret name changes between files. `myapp/production/secrets`\n\nfor production, `myapp/staging/secrets`\n\nfor staging. Run `kamal deploy -d staging`\n\nand Kamal reads from the staging file.\n\nBoth secrets live in AWS. No staging credentials in plaintext either. This matters for SOC 2 because auditors check every environment.\n\n## Done\n\nNo more secrets in plaintext. SOC 2 and GDPR requirements met. Hetzner bill stays under 5 euro a month.\n\nBig thanks to the [AWS docs team](https://aws.amazon.com/secrets-manager/), the [Kamal maintainers](https://kamal-deploy.org/docs/commands/secrets/), and [Hetzner](https://hetzner.cloud/) for keeping hosting affordable. Hope this saves you the same headaches I ran into. Now back to building.", "url": "https://wpnews.pro/news/storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner", "canonical_source": "https://dev.to/godsloveady/storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner-vps-2262", "published_at": "2026-05-23 11:48:51+00:00", "updated_at": "2026-05-23 12:02:00.792245+00:00", "lang": "en", "topics": ["cloud-computing", "developer-tools", "cybersecurity"], "entities": ["AWS Secrets Manager", "Kamal", "Hetzner", "Docker Hub", "SOC 2", "GDPR", "DEEPGRAM_API_KEY", "ASSEMBLY_AI_API_KEY"], "alternates": {"html": "https://wpnews.pro/news/storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner", "markdown": "https://wpnews.pro/news/storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner.md", "text": "https://wpnews.pro/news/storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner.txt", "jsonld": "https://wpnews.pro/news/storing-kamal-secrets-in-aws-secrets-manager-and-deploying-to-a-cheap-hetzner.jsonld"}}