Deploy Bitwarden (vaultwarden) on AWS spot instances with Amazon Linux 2023, Docker, Cloudflare Tunnel, and R2 storage.
Goal: under $2/month, zero public IPv4 costs, auto-healing spot instances.
Instance boots via cloud-init: create swap -> install Docker + cloudflared -> fetch runtime secrets from SSM -> mount EBS volume -> deploy scripts -> start vaultwarden (Docker) + Cloudflare Tunnel (native systemd). Cloudflare Tunnel proxies HTTPS + SSH -- no public IPv4 needed.
- OpenTofu installed
- AWS CLI configured (see Authentication below)
- Cloudflare account (free tier) with:
- API token (Zone:DNS, Tunnel, R2, Access permissions)
- Account ID
- Zone ID for your domain
- R2 API credentials with
Object Read & Writepermission on thebitwarden-backupsbucket
This project uses temporary session tokens via AWS STS with MFA. Long-lived IAM credentials are never used directly.
1. Create IAM user with MFA
Create an IAM user with programmatic access. Assign a virtual MFA device. Attach this policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Action": ["sts:GetSessionToken", "iam:ListMFADevices"],
"Effect": "Allow",
"Resource": "*"
}
]
}2. Configure AWS CLI profiles
~/.aws/config:
[default]
[profile temp]
region = eu-north-1~/.aws/credentials:
[default]
[temp]
aws_access_key_id = AKIAXXXXXXXXXXXXXXXX
aws_secret_access_key = ...The temp profile stores your long-lived IAM credentials. The default profile gets temporary session tokens written by the script below.
3. Generate temporary credentials
./scripts/aws-login.sh <iam-username> <mfa-code>4. Verify
aws sts get-caller-identityAll tools (tofu, aws CLI) use the default profile automatically -- no --profile flag needed.
See scripts/README.md for full details.
See section Get Cloudflare credentials below.
You need four Cloudflare credentials for terraform.tfvars:
Cloudflare API Token:
- Log in to Cloudflare dashboard, go to My Profile, then API Tokens
- Create Token, use template "Edit Cloudflare Workers" (or create custom)
- Required permissions:
- Account-level:
Cloudflare Tunnel:EditWorkers R2 Storage:EditAccess: Apps and Policies:Edit
- Zone-level (select your domain):
DNS:EditZone Settings:EditSingle Redirect:EditTransform Rules:Edit
- Account-level:
- Copy the token (shown once)
Cloudflare Account ID:
Found in Cloudflare dashboard right sidebar under "API", labeled Account ID.
Cloudflare Zone ID:
Select your domain in Cloudflare dashboard, go to Overview, find Zone ID in the right sidebar.
R2 API Credentials:
Separate token used only for backups. Scoped to R2 only, not the main API token above.
- Cloudflare dashboard -> Storage & databases -> R2 Object Storage -> Overview -> API Tokens {} Manage
- Create Account API token with
Object Read & Writepermission - Set scope to Apply to all buckets in this account (including newly created buckets)
-- the
bitwarden-backupsbucket doesn't exist yet (tofu creates it). Once deployed, edit the token and restrict scope tobitwarden-backupsonly. - Copy Access Key ID and Secret Access Key
Tunnel secret:
Managed by tofu (random_bytes), stored in SSM as /bitwarden/tunnel-secret (SecureString) and fetched at instance boot before cloudflared starts. It also exists plaintext in Terraform state (random_bytes + tunnel resources) -- unavoidable for a Terraform-managed tunnel. Instance user-data carries no secret.
Create terraform.tfvars in the project root:
domain = "vaultwarden.yourdomain.com"
cloudflare_api_token = "<your-api-token>"
cloudflare_account_id = "<your-account-id>"
cloudflare_zone_id = "<your-zone-id>"
ssh_public_key = "<content-of-~/.ssh/id_ed25519.pub>"
ssh_allowed_ips = ["<your-public-ip>/32"]
env_content = <<EOF
DOMAIN=<your-domain>
R2_ACCESS_KEY_ID=<your-r2-access-key-id>
R2_SECRET_ACCESS_KEY=<your-r2-secret-access-key>
R2_ENDPOINT_URL=https://<your-account-id>.r2.cloudflarestorage.com
VAULTWARDEN_ADMIN_TOKEN=<generate-a-strong-random-token>
EOFGenerate the admin token:
openssl rand -base64 48To find your public IP:
curl -s ifconfig.meInclude any additional IPs or CIDR ranges you want to allow SSH access:
ssh_allowed_ips = ["203.0.113.1/32", "198.51.100.0/24"]
terraform.tfvarsis in.gitignore-- it will never be committed. Theenv_contentvalue is stored in AWS SSM Parameter Store as a SecureString -- no local encryption files needed.
tofu init
tofu plan -var-file=terraform.tfvars
tofu apply -var-file=terraform.tfvarsNo age key needed. All secrets (R2 credentials, admin token) are stored directly in SSM via the env_content variable.
cloudflared access ssh --hostname ssh.example.com- Free (up to 50 users)
- Access controlled by
var.ssh_allowed_ips(IP ranges in terraform.tfvars) - No AWS dependency
aws ssm start-session --target <instance-id>- IAM-based auth
- Requires
AmazonSSMManagedInstanceCoreon instance role
On instance launch, cloud-init runs data/user-data.sh:
- Create 1GB swap file (t4g.nano needs it for dnf / installs)
- Enable SSM Agent dual-stack (required for IPv6-only instances)
- Install Docker + cloudflared (native, from cloudflared RPM repo)
- Fetch runtime secrets from SSM (
/bitwarden/envSecureString) -- uses dual-stack AWS endpoint - Write cloudflared tunnel credentials + ingress rules
- Start cloudflared as a systemd service
- Discover and format EBS volume, mount at
/data/ - Create
/data/vaultwardenand/data/scriptsdirectories - Deploy backup, restore, and spot termination scripts to
/data/scripts/ - Start spot-termination-notifier as a systemd service
- Start vaultwarden via
docker run
All network-dependent operations use exponential backoff retry (up to 5 attempts).
See variables.tf for defaults.
| Variable | Description |
|---|---|
domain |
Vaultwarden domain |
cloudflare_api_token |
Cloudflare API token |
cloudflare_account_id |
Cloudflare account ID |
cloudflare_zone_id |
Cloudflare zone ID for the domain |
ssh_public_key |
SSH public key for EC2 access |
ssh_allowed_ips |
IP ranges allowed to SSH |
env_content |
Runtime .env content (stored in SSM SecureString) |
| Variable | Description |
|---|---|
DOMAIN |
Vaultwarden domain (must match domain in terraform.tfvars) |
R2_ACCESS_KEY_ID |
R2 API access key |
R2_SECRET_ACCESS_KEY |
R2 API secret key |
R2_ENDPOINT_URL |
R2 S3-compatible endpoint URL |
VAULTWARDEN_ADMIN_TOKEN |
Admin panel token (optional) |
| Variable | Default | Description |
|---|---|---|
name |
"bitwarden" |
Resource name prefix |
instance_types |
["t4g.nano","t4g.micro"] |
Spot instance types |
use_existing_vpc |
false |
Use existing VPC by ID |
existing_vpc_id |
"" |
VPC ID when use_existing_vpc=true |
existing_subnet_ids |
[] |
Subnet IDs when use_existing_vpc=true |
tags |
{} |
Resource tags |
aws_region |
"eu-north-1" |
AWS region (eu-north-1 is cheaper) |
Caveats:
instance_typesare all passed asmixed_instances_policyoverrides (100% spot); AWS selects the available/cheapest and falls back across types.existing_subnet_idsmust be public (IPv6-capable); there is no NAT gateway.- The ASG and EBS volume are pinned to a single AZ. Multi-type spot fallback helps within that AZ but not if the whole AZ lacks capacity.
This project was redesigned from the v1 setup (S3 + Traefik + Docker Compose + MariaDB). The migration switches from MariaDB to SQLite and replaces S3 with R2, removing iam.tf, s3.tf, network.tf, and locals.tf.
Breaking change -- data loss risk. Running terraform apply on existing state will schedule destruction of old S3 bucket, IAM roles, and network resources. A migration plan that includes terraform state rm for deleted resources before applying is required if migrating existing state.
For new deployments (no existing state), no action needed.
| Output | Description |
|---|---|
url |
https://<domain> |
iam_role_name |
Instance IAM role name |
sg_id |
Security group ID |
volume_id |
EBS volume ID |
r2_backup_bucket |
R2 backup bucket name (resides in EU/WEUR for data residency) |
tunnel_id |
Cloudflare tunnel ID |
| Path | Contents |
|---|---|
/data/vaultwarden/ |
Vaultwarden SQLite database + attachments (EBS) |
/data/scripts/backup.sh |
Backup script -- stops vaultwarden, tars to R2 |
/data/scripts/restore.sh |
Restore script -- download from R2, extract |
/data/scripts/r2-config.sh |
Shared R2 config loader -- sourced by backup/restore |
/data/scripts/AWS_SpotTerminationNotifier.sh |
Graceful stop on spot termination |
/etc/vaultwarden/.env |
Runtime environment (from SSM) |
/etc/cloudflared/credentials.json |
Cloudflare tunnel credentials (template-substituted) |
/etc/cloudflared/config.yml |
Cloudflare tunnel ingress rules |
/etc/systemd/system/spot-termination-notifier.service |
Spot term handler systemd unit |
Backups are stored in R2 as s3://bitwarden-backups/<YYYYMMDD-HHMMSS>-bitwarden-backup.tar.gz. Restore from a specific timestamp:
/data/scripts/restore.sh 20260101-120000Or restore from the latest backup automatically:
/data/scripts/restore.sh --latestThe --latest mode is also called automatically during instance bootstrap if the EBS volume is empty (e.g., new deploy or replaced volume).
List available backups:
aws s3 ls s3://bitwarden-backups/ --endpoint-url <R2_ENDPOINT_URL>| Item | Cost |
|---|---|
| EC2 spot t4g.nano (eu-north-1) | ~$1.46 |
| EBS gp3 5GB | $0.40 |
| R2 storage (<5GB) | $0 (free tier) |
| Cloudflare DNS + Tunnel | $0 (free tier) |
| Data transfer out | ~$0 |
| Public IPv4 | $0 |
| Total | ~$1.86/month |
Cost does not change after AWS free tier expires -- none of our costs depend on it.