Getting Started

Set up webform-relay as a vendored subproject of your own repo and deploy it to AWS: vendor the source with v, include its taskfile from your own Taskfile.yml, fill in two dotenv files, and deploy. Covers creating a least-privilege deploy user, first-time deployment, updates, rollbacks, the optional S3 submissions storage relay, teardown, and the flow’s known gaps.

No AWS CLI, SAM CLI, Go toolchain, or ~/.aws/credentials needed on the host — everything runs in Docker.

Don’t fork this repo. A fork diverges immediately: bug fixes and IAM policy corrections made upstream never reach it short of a manual diff-and-reapply. Vendoring keeps webform-relay as read-only source you never edit, so an update is a version bump rather than a merge.

Two other audiences are served elsewhere:

  • Working on webform-relay (a direct clone, running the tests, the local SAM API, the docs site) — see the Contribution Guide.
  • Bringing the source in without v — a plain copy of a release, or a git submodule — see Vendoring alternatives. Everything on this page after step 2 applies unchanged; only how the source arrives differs.

Prerequisites

  • Docker running locally
  • Task 3+ installed
  • v installed
  • Your own project repo, with its own Taskfile.yml (or willing to gain one)
  • An AWS account, and an identity you can use once to create the deploy user. It does not have to be root or a full admin — see The bootstrap identity

1. Install v

brew install frob/v/v
# or: curl -sSf https://raw.githubusercontent.com/frob/v/main/install.sh | sh

See the v README for other install methods.


2. Vendor webform-relay into your project

From your project’s repo root:

v add https://gitlab.com/frob/webform-relay <tag>

Pick a released tag (see the changelog), not a branch — you want v update to be a deliberate, informed jump between releases, not a silent moving target. This creates vendor/gitlab.com/frob/webform-relay/ and records the exact commit in your project’s vendors.toml.

Two directories, not one

From here on, two directories matter, and it is worth being precise about which is which:

  • The vendored sourceDockerfile, template.yaml, internal/, cmd/, deploy/, taskfiles/ — at vendor/gitlab.com/frob/webform-relay/. This directory is overwritten wholesale on every v update. Never edit anything in it — any change is silently lost on the next update.
  • Your project root — holds your Taskfile.yml, .env, .env.project, and webform.yaml. v update never touches it.

taskfiles/deploy.yml inside the vendored source is built specifically for this split. Every task in it either mounts the vendored source (build context, template.yaml, deploy/deploy-policy.json, the Go code) via {{.TASKFILE_DIR}}, or mounts your project root (.env, .env.project, webform.yaml) via {{.ROOT_DIR}}. These are Task’s own built-in variables: TASKFILE_DIR resolves to wherever the currently-running taskfile physically sits, and ROOT_DIR to the top-level taskfile’s directory — which, because Task requires dotenv: to be declared in the top-level taskfile, is always where your dotenv files are.

webform-relay’s own root Taskfile.yml includes taskfiles/deploy.yml exactly the way yours will, so this path is exercised on every contributor’s machine, not only in downstream projects.


3. Include the vendored taskfile in your local taskfile

Create (or extend) your project’s own Taskfile.yml at your project root — not inside vendor/:

version: '3'

# .env holds secrets and is gitignored; .env.project holds non-secret
# deployment config and is committed. See step 4.
dotenv: ['.env', '.env.project']

includes:
  relay:
    taskfile: ./vendor/gitlab.com/frob/webform-relay/taskfiles/deploy.yml
    dir: ./vendor/gitlab.com/frob/webform-relay

tasks:
  default:
    cmds:
      - task --list

Four things about that block:

  • dotenv: must be declared here, in your top-level taskfile, never in the included one. Task refuses to load an included taskfile that declares its own dotenv:, which is why taskfiles/deploy.yml has none — and it is what makes your project root the directory where .env, .env.project, and webform.yaml are looked for.
  • No vars: forwarding is needed. dotenv sets real process environment variables, which every included taskfile already sees.
  • The namespace is yours to choose. relay: is what these docs use; pick another and every command below changes prefix to match.
  • dir: must point at the vendored source root, not at its taskfiles/ subdirectory.

If your project already has a Taskfile.yml, add both the dotenv: line and the relay: include to it. Adding only the include is the easy mistake: the tasks then appear in task --list and run, but with nothing in the environment, so every one of them stops at a precondition (CONFIG_BUCKET is not set, AWS_ACCESS_KEY_ID is not set in .env) no matter how completely .env and .env.project are filled in. If your Taskfile already declares dotenv:, extend the list rather than replacing it — but keep .env ahead of .env.project (see step 4 for why order matters).

Confirm the wiring before going further:

task --list-all | grep relay:

You should see the full deploy task list (relay:deploy:app, relay:aws:bootstrap, relay:info, …). If it is empty, the taskfile: path is wrong.

When a task says a variable “is not set” but it clearly is

Both causes are wiring, not the dotenv files:

  • Your top-level Taskfile.yml has no dotenv: line (or lists different filenames). Task only reads dotenv from the top-level Taskfile, and silently ignores a dotenv: inside an included one — so the relay: tasks run with an empty environment. Check with grep dotenv Taskfile.yml.
  • You ran task from inside the vendored directory. That tree carries webform-relay’s own root Taskfile.yml, which declares its own dotenv and includes the same deploy tasks under the same relay: name — so task relay:... works there, but resolves .env and .env.project inside the vendored directory, where your config is not. Check with pwd.

task relay:info printing empty or default values is the same symptom.


4. Set up .env and .env.project

Deployment config is split across two files, both loaded by the dotenv: line above, and both living in your project root — copy the templates out of the vendored subdirectory:

cp vendor/gitlab.com/frob/webform-relay/.env.example  .env
cp vendor/gitlab.com/frob/webform-relay/.env.project  .env.project
FileHoldsCommitted?
.envAWS access keys (deploy and bootstrap-admin) and FORM_TOKEN_SECRETNo — add it to your .gitignore
.env.projectregion, CONFIG_BUCKET, CONFIG_KEY, SAM_ARTIFACTS_BUCKET, STACK_NAME, CACHE_TTL, SES_FROM_ADDRESS, ALLOWED_ORIGINS, throttle limits, SUBMISSIONS_STACK_NAMEYes

The split exists because only the credentials are actually secret. Which region, bucket, and stack a project deploys to is a fact about the project, and committing it means a new contributor or a CI runner deploys to the right place without being told — where a single gitignored .env forces that knowledge to travel out of band, and drifts between machines.

Make sure your project’s .gitignore ignores .env but not .env.project. An exact-match line does both:

# Credentials and signing secrets (see the vendored .env.example).
# Non-secret deploy config lives in .env.project, which IS committed.
.env

.env is listed first in the dotenv: array because Task takes the first file that sets a given key. So .env wins over .env.project, and a variable already exported in the shell wins over both. That ordering is what lets you override a committed value locally — put the key in .env — without editing .env.project.

Fill in the credentials

In .env, set ADMIN_AWS_ACCESS_KEY_ID / ADMIN_AWS_SECRET_ACCESS_KEY to an existing admin/root identity’s keys. These are used only by the bootstrap task in step 5, to create the deploy user. If you would rather create the deploy user yourself (console, or your own one-off CLI), leave the ADMIN_* fields blank and fill AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in with that user’s keys directly.

In .env.project, set CONFIG_BUCKET and SAM_ARTIFACTS_BUCKET to the S3 bucket names you want (they must be globally unique), and adjust AWS_DEFAULT_REGION / ADMIN_AWS_DEFAULT_REGION if you are not in us-east-1.


5. Bootstrap the deploy user and S3 buckets

The bootstrap identity

Creating the deploy user needs a second identity — the ADMIN_* credentials in .env — because the deploy user cannot create itself. That identity is used by exactly two tasks and needs a correspondingly small set of permissions, all scoped to that one user’s name. It does not need to be your root account or a full administrator.

TaskWhat it callsWhen
aws:bootstrap-useriam:CreateUser, iam:CreatePolicy, iam:AttachUserPolicy, iam:CreateAccessKeyOnce, at setup
aws:update-deploy-policyiam:GetUser, iam:GetPolicy, iam:CreatePolicyVersion (plus ListPolicyVersions/DeletePolicyVersion to stay under the five-version cap), iam:AttachUserPolicyWhenever STACK_NAME changes or the deploy policy gains a permission

The deploy policy is attached as a customer-managed policy named after the deploy user, not as an inline user policy: inline policies are capped at 2048 characters and this one is around 3.4KB. A refresh creates a new default version of that managed policy rather than overwriting a document, so aws:update-deploy-policy is re-runnable and reversible in the console.

The same policy covers both, so keep the bootstrap identity around rather than deleting it after setup — refreshing the deploy policy needs it again.

Print the policy it does need — this runs locally and takes no credentials:

task relay:aws:bootstrap-policy > bootstrap-policy.json

Then, once, in the AWS console: IAM → Users → Create user (call it something like webform-relay-bootstrap, no console access), Next → Attach policies directly → Create policy → JSON, paste the file, save and attach it, then Security credentials → Create access key and put that key in ADMIN_AWS_ACCESS_KEY_ID / ADMIN_AWS_SECRET_ACCESS_KEY in .env.

Render it after setting DEPLOY_USER_NAME in .env.project, not before: the policy names the deploy user explicitly, so one rendered against the default webform-relay-deploy will refuse to create a user called anything else (iam:CreateUser ... AccessDenied from aws:bootstrap-user). If that happens, re-run the task and replace the policy attached to the bootstrap user in the console — the bootstrap identity deliberately cannot edit its own policy.

The policy is deploy/bootstrap-policy.json in the vendored source, rendered for your DEPLOY_USER_NAME — two statements, both scoped to arn:aws:iam::*:user/<your deploy user>:

StatementActionsWhy
DeployUseriam:CreateUser, GetUser, TagUser/UntagUser, AttachUserPolicy, DetachUserPolicy, ListAttachedUserPolicies, PutUserPolicy, GetUserPolicy, ListUserPolicies, DeleteUserPolicy, DeleteUserCreate the deploy user and attach its policy
DeployUserAccessKeysiam:CreateAccessKey, ListAccessKeys, UpdateAccessKey, DeleteAccessKeyIssue its access key, and rotate or revoke it later
DeployPolicyiam:CreatePolicy, GetPolicy, CreatePolicyVersion, ListPolicyVersions, GetPolicyVersion, DeletePolicyVersion, TagPolicy/UntagPolicy, DeletePolicyCreate and version the managed deploy policy, scoped to arn:aws:iam::*:policy/<deploy user name>

Two things worth being clear about. IAM does not require the caller to already hold the permissions it grants, so this identity can create a user carrying the much broader deploy policy — it is a smaller blast radius than root, not a sandbox. And between setup and the next policy refresh nothing uses these credentials, so the access key can come out of .env and be re-created when task relay:aws:update-deploy-policy next needs it.

If you would rather not create a bootstrap identity at all, create the deploy user by hand instead: attach deploy/deploy-policy.json (rendered for your stack name — task relay:aws:update-deploy-policy does that for an existing user, or run python3 deploy/render-policy.py deploy/deploy-policy.json --roles <STACK_NAME> yourself), leave the ADMIN_* keys blank, and skip aws:bootstrap-user.

Run the bootstrap

task relay:tools:build      # build the SAM/AWS CLI tools image (once)
task relay:aws:bootstrap    # creates the IAM deploy user + both S3 buckets

task relay:aws:bootstrap runs three tasks:

  • relay:aws:bootstrap-user — creates a webform-relay-deploy IAM user using the ADMIN_* credentials in .env, attaches the least-privilege policy at deploy/deploy-policy.json in the vendored source (permissions across CloudFormation, S3, ECR, Lambda, API Gateway, IAM, and CloudWatch Logs — scoped to what SAM needs to deploy this stack), and creates an access key. It prints the new AccessKeyId / SecretAccessKey — copy them into AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in .env.

    The policy’s IAM statements are the one part that is not a fixed document: CloudFormation names the roles it creates after the stack (<stack-name>-SubmitFunctionRole-XXXX), so the policy is rendered at bootstrap time to allow role names matching your STACK_NAME as well as the default *webform-relay*. If you change STACK_NAME later — or upgrade to a webform-relay whose policy needs a permission it didn’t before — refresh it:

    task relay:aws:update-deploy-policy

    Skipping that shows up mid-deploy as not authorized to perform: iam:CreateRole, which leaves the stack in ROLLBACK_FAILED (see Recovering from a failed deploy). Set DEPLOY_USER_NAME in .env.project if your deploy user isn’t named webform-relay-deploy.

  • relay:aws:bootstrap-bucket — creates the S3 bucket named by CONFIG_BUCKET in .env.project, using those deploy-user credentials.

  • relay:aws:bootstrap-artifacts-bucket — creates a plain S3 bucket named by SAM_ARTIFACTS_BUCKET in .env.project for sam deploy to upload build artifacts into. This is deliberately not SAM’s own --resolve-s3 auto-managed bucket: that flow provisions a shared CloudFormation stack named aws-sam-cli-managed-default with its own bucket policy, versioning, encryption config, and an SSM parameter — each needing its own IAM permission and each a fresh way for that stack to get stuck in ROLLBACK_FAILED if any one permission is missing. A bucket we create ourselves with a plain s3 mb sidesteps all of that.

You can also run any step alone: task relay:aws:bootstrap-user, task relay:aws:bootstrap-bucket, or task relay:aws:bootstrap-artifacts-bucket.


6. Write and upload your form config

Write webform.yaml at your project root (sibling to .env, not inside vendor/) — see the README and the Field Model reference for the format. Then upload it to the bucket step 5 created:

task relay:upload:config CONFIG=webform.yaml

CONFIG= is the local path, resolved against your project directory, never the vendored source — so you never need to know or care where inside vendor/ anything physically lives. It is distinct from CONFIG_KEY, the destination S3 key the deployed Lambda reads, and defaults to it: if your file is named after the key it uploads to (the usual case), plain task relay:upload:config is enough. Pass CONFIG= only when the two differ, e.g. CONFIG=forms/prod.yaml uploading to s3://$CONFIG_BUCKET/$CONFIG_KEY. validate:config resolves it the same way; info reads the live object at CONFIG_KEY.

This runs aws s3 cp inside the same dockerized tools image everything else uses, reading the destination bucket and key from .env.project — no local AWS CLI or credentials on the host.


7. First deployment

Check the remaining stack parameters in .env.projectSTACK_NAME (defaults to webform-relay), CONFIG_KEY (defaults to webform.yaml), and optionally CACHE_TTL / SES_FROM_ADDRESS (leave blank if not using email outputs) — then deploy:

task relay:build:container
task relay:deploy:app

This is fully non-interactive: it reads STACK_NAME, CONFIG_BUCKET, CONFIG_KEY, CACHE_TTL, SES_FROM_ADDRESS, and SAM_ARTIFACTS_BUCKET from the dotenv files (along with the optional parameters described below) and passes them as --s3-bucket and --parameter-overrides, with --resolve-image-repos so SAM still manages the per-function ECR repo automatically (that part doesn’t go through the fragile managed-stack flow). No samconfig.toml is created or needed — the dotenv files are the only source of truth, so this same command works for the first deploy and every one after.

If you’d rather step through SAM’s interactive prompts (e.g. to review the change set or explore parameters manually), task relay:deploy:guided runs sam deploy --guided — but note SAM always prompts in guided mode regardless of what the dotenv files hold, so it does not save you typing once they are filled in.

The remaining stack parameters are all optional: ALLOWED_ORIGINS, THROTTLE_RATE_LIMIT, THROTTLE_BURST_LIMIT (see Tuning CORS and throttling below) in .env.project, and FORM_TOKEN_SECRET in .env. Left blank, the throttle and CORS values fall back to template.yaml’s defaults.

FORM_TOKEN_SECRET is the HMAC signing secret for the min-fill anti-spam token, and is needed only if a form sets spam.min_fill_seconds — such a form fails closed with a 500 when the secret is unset. Generate one with openssl rand -hex 32. It lives in .env, not .env.project, and it is the one parameter passed conditionally: blank, it is omitted from sam deploy altogether, so an already-deployed stack keeps whatever secret it has instead of having it blanked. Note that it is passed as a command-line --parameter-override inside the tools container, so it is visible to anything that can read that container’s process list, and CloudFormation stores it as a NoEcho parameter — for production, resolve it from SSM Parameter Store / Secrets Manager instead.

Tuning CORS and throttling

Both are stage-level settings on the HTTP API — change them by redeploying with new parameter values (task relay:deploy:app after editing .env.project), no code change required.

  • AllowedOrigins is a browser-read allowlist: it controls which origins may read the endpoint’s response from a fetch/XHR call. It does not block the POST itself — a curl or bot ignores CORS entirely (see the CORS is not CSRF note in the Production Hardening plan). Restrict it to the sites that embed your forms if you use JS fetch submission; the * default is fine for full-page <form> POSTs, which don’t read the response.
    • Each entry must be a full origin, not a hostname: a scheme, a host, an optional port, and nothing else — https://www.example.com, http://localhost:3000, https://*.example.com, or *. A bare www.example.com, a trailing slash, a path, or a space after a comma is rejected by API Gateway with Invalid format for origin. deploy:app checks the value before deploying, because CloudFormation only finds out while creating the API — leaving a half-created stack that has to be torn down before you can retry.
    • htmx note: AllowHeaders already includes the htmx request headers (HX-Request, HX-Target, HX-Current-URL, and friends). htmx adds these to every request, and being non-simple they force a CORS preflight on cross-origin calls — so an htmx site on a different origin than the relay needs them allowlisted (they are, by default). AllowMethods also permits GET for the htmx form/token endpoints, and preflight responses are cached for 5 minutes (MaxAge: 300) to cut repeat preflight chatter.
  • ThrottleRateLimit / ThrottleBurstLimit cap total request volume across all clients, protecting against runaway Lambda/SES/downstream cost on this public, unauthenticated endpoint. They are a global cap, not per-IP — per-IP throttling needs AWS WAF (a later hardening step). Once the rate is exceeded, API Gateway returns 429 Too Many Requests.

Verify after deploying to a test stack:

# Throttle: a burst above the rate limit should start returning 429s.
for i in $(seq 1 50); do \
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST "$ENDPOINT" --data 'name=x'; \
done | sort | uniq -c

# CORS: an allowed Origin is reflected in the preflight response.
curl -s -i -X OPTIONS "$ENDPOINT" \
  -H 'Origin: https://example.com' \
  -H 'Access-Control-Request-Method: POST' | grep -i access-control-allow-origin

After deployment, SAM prints the endpoint URL:

Outputs
-------
ApiEndpoint  https://<api-id>.execute-api.<region>.amazonaws.com/api/v1/submit/{form}

8. Subsequent deployments

Same command as the first deploy — it’s idempotent and re-reads the dotenv files every time:

task relay:deploy:app

If you only changed the config YAML (not the code), upload to S3 and skip the deploy — changes take effect within CACHE_TTL.

task relay:upload:config CONFIG=webform.yaml

9. Updating the vendored webform-relay

When a new release fixes something you care about (check the changelog — it calls out breaking infrastructure changes, like an API Gateway ID rotation, explicitly):

v update https://gitlab.com/frob/webform-relay <new-tag>
task relay:build:container
task relay:deploy:app

That’s it — no diff to review, no merge conflict, because you never edited anything inside vendor/. Your Taskfile.yml, .env, .env.project, and webform.yaml are untouched by the update; check the new release’s .env.project against yours if the changelog mentions a new parameter.

If you find yourself wanting to change something inside the vendored tree, don’t — add a differently-named task to your own Taskfile.yml, or open an issue/PR upstream if the customization is generally useful. See If you need to customize a task.


10. Deploying from CI/CD

Set these as environment variables in your CI runner instead of committing a .env file — the dotenv: list only loads literal files, but task also picks up variables already present in the process environment, and those take precedence over both files. .env.project is committed, so CI already has the non-secret half; only the secrets need to come from the runner’s secret store:

AWS_ACCESS_KEY_ID=<key-id>
AWS_SECRET_ACCESS_KEY=<secret>
FORM_TOKEN_SECRET=<hmac-secret>

Everything else — AWS_DEFAULT_REGION, STACK_NAME, CONFIG_BUCKET, CONFIG_KEY, SAM_ARTIFACTS_BUCKET, CACHE_TTL, SES_FROM_ADDRESS, ALLOWED_ORIGINS, THROTTLE_RATE_LIMIT, THROTTLE_BURST_LIMIT — comes from the committed .env.project. Override any of them per-job by exporting the same name.

Leave FORM_TOKEN_SECRET unset if the stack’s existing secret should be kept, since a blank value is omitted from the deploy rather than pushed as an empty override.

Then run:

task relay:tools:build
task relay:build:container
task relay:deploy:app

For GitHub Actions, store the keys as repository secrets and reference them in your workflow (the checkout must include the vendored tree — it is committed to your repo, so a plain actions/checkout is enough):

- name: Deploy
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  run: |
    task relay:tools:build
    task relay:build:container
    task relay:deploy:app

On GitLab CI, the same shape — the keys as masked, protected CI/CD variables (Settings → CI/CD → Variables), and a job that can talk to a Docker daemon, since every task runs a container:

deploy:
  image: docker:27
  services:
    - docker:27-dind
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_CERT_PATH: "/certs/client"
    DOCKER_TLS_VERIFY: 1
  before_script:
    - apk add --no-cache go-task
  script:
    - task relay:tools:build
    - task relay:build:container
    - task relay:deploy:app

AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY come straight from the CI/CD variables — the tasks read them from the environment, so no .env is needed on the runner. .env.project is committed, so the runner already has the region, bucket names, and stack name.


11. Roll back to the previous version

CloudFormation keeps the last successfully deployed state of the stack. Roll back to it with:

task relay:deploy:rollback

This runs cloudformation rollback-stack against STACK_NAME, waits for the rollback to reach a terminal state, and prints the resulting stack status. Like every other task here it runs in the tools container, so no host AWS CLI is involved. The equivalent by hand is the AWS Console under CloudFormation → Stacks → webform-relay → Stack actions → Roll back stack.

Two things it does not cover:

  • A stack stuck in UPDATE_ROLLBACK_FAILED needs aws cloudformation continue-update-rollback (the deploy user has the permission; there’s no task wrapper, since it usually needs --resources-to-skip chosen by hand).
  • A config-only change. Nothing was deployed, so there’s nothing to roll back — re-upload the previous YAML instead, and it takes effect within CACHE_TTL:
task relay:upload:config CONFIG=webform.yaml.bak

Rolling back the vendored version is separate: v update back to the previous tag, then task relay:build:container && task relay:deploy:app.

Recovering from a failed deploy

A deploy that fails while creating a stack for the first time — most often an IAM permission the deploy user is missing — cannot be rolled back to anything, because there is no previous state. CloudFormation deletes what it managed to create and leaves the stack in ROLLBACK_COMPLETE, or, if it couldn’t delete some of it either (same missing permission), in ROLLBACK_FAILED. A stack in either state cannot be updated; it has to be deleted and re-created.

The typical sequence, using the S3 submissions stack as the example:

task relay:aws:update-deploy-policy        # 1. fix the permission
task relay:deploy:submissions-teardown     # 2. delete the failed stack
task relay:deploy:submissions              # 3. deploy again

Step 2 is what actually clears ROLLBACK_FAILED: sam delete re-runs the deletion, which now succeeds with the corrected policy. For the app stack the same three steps apply with task relay:deploy:teardown and task relay:deploy:app.

If deletion still fails on one resource, delete the stack from the AWS console (CloudFormation → Stacks → the stack → Delete) and tick Retain on that resource, then remove it by hand — an orphaned IAM role costs nothing but will collide by name on the next attempt.


12. Tear down

Remove all AWS resources created by the stack:

task relay:deploy:teardown

This removes the Lambda function, API Gateway, and IAM roles. It does not delete the config S3 bucket, the SAM artifact bucket, or the webform-relay-deploy IAM user — remove those manually if you no longer need them. There’s no task wrapper for these: deleting the deploy user’s own credentials and the buckets it needs isn’t something to make one command away, and the IAM deletions need the ADMIN_* identity anyway. Run them wherever you have an AWS CLI (including docker run --rm -it webform-relay-tools sh if you don’t have one on the host):

aws s3 rb s3://<your-config-bucket> --force
aws iam delete-access-key --user-name webform-relay-deploy --access-key-id <id>
aws iam delete-user-policy --user-name webform-relay-deploy --policy-name webform-relay-deploy
aws iam delete-user --user-name webform-relay-deploy

To remove webform-relay from your project entirely, drop the relay: include from your Taskfile.yml and v remove the vendored directory.


13. Optional: the S3 submissions storage relay

There is no native s3 output type. To keep a raw copy of every submission, deploy the API Gateway → S3 proxy that ships in the vendored source (deploy/s3-relay-template.yaml — API Gateway writes the request body straight to S3, no Lambda involved) and point an http_post output at it:

task relay:deploy:submissions     # prints SubmissionsEndpoint in the outputs
task relay:submissions:api-key    # prints the x-api-key header value

Use SubmissionsEndpoint as the output’s url and the key as an x-api-key header. Then read what landed:

task relay:submissions:list                 # key, size, timestamp
task relay:submissions:read KEY=<object-key> # one submission, URL-decoded
task relay:submissions:dump                 # every submission, newest last

This is a separate stack (<STACK_NAME>-submissions by default, overridable with SUBMISSIONS_STACK_NAME in .env.project), so it deploys, redeploys, and tears down independently of the app stack:

task relay:deploy:submissions-teardown

Note that CloudFormation cannot delete a non-empty bucket, so teardown fails if submissions are still stored. Empty the bucket first (deliberately manual — it destroys the stored submissions):

aws s3 rm s3://<submissions-bucket> --recursive

Store every submission in S3 walks the whole thing through step by step — what the stack creates, the config block, reading submissions back, costs and retention, and what each failure mode looks like.


Limitations and known gaps

Things this deployment flow deliberately does not do yet. None of them block a working deployment; all of them are things to know before running one in production.

Secret handling

.env holds long-lived static IAM access keys in plaintext on disk. It is gitignored — and the .env/.env.project split exists so that nothing else has to be gitignored alongside them — but it is not encrypted, not rotated, and not scoped per-machine. FORM_TOKEN_SECRET is passed to sam deploy as a command-line --parameter-override, so it is visible to anything that can read the tools container’s process list, and CloudFormation then stores it as a NoEcho parameter (hidden in the console, still a stack parameter). There is no SSM Parameter Store / Secrets Manager integration, and no OIDC / short-lived credential path for CI — a CI runner needs the same static keys, held as repository secrets. For a production posture, resolve FORM_TOKEN_SECRET from SSM or Secrets Manager in the template and give CI an assumable role instead of a user’s keys.

task relay:aws:bootstrap-user also prints the new access key pair to stdout, where it lands in terminal scrollback and any CI log. Rotate it if that scrollback is not private.

The ADMIN_* bootstrap credentials are the smaller problem they used to be — task relay:aws:bootstrap-policy prints a policy scoped to the deploy user’s name, so they need not be root or a full admin (see The bootstrap identity) — but IAM does not stop an identity from granting permissions it lacks, so that bootstrap user can still create a user holding the full deploy policy.

One .env.project, one environment

There is no ENV= selector. Deploying dev and prod stacks means keeping separate .env.project files and swapping them by hand (or exporting the variables in the shell, which takes precedence — see Deploying from CI/CD). STACK_NAME is what actually separates the stacks, so the failure mode of swapping the wrong file is deploying over the wrong environment. Check task relay:info before a deploy if you keep more than one.

Throttling is global, not per-IP

ThrottleRateLimit / ThrottleBurstLimit cap total request volume across all clients. A single abusive client can consume the whole budget and return 429s to everyone else. HTTP API has no per-IP usage plans; per-IP limiting needs AWS WAF in front of the API, which this stack does not provision. See Tuning CORS and throttling.

Rollback covers one case

task relay:deploy:rollback handles a bad deploy on a healthy stack. A stack in UPDATE_ROLLBACK_FAILED needs a hand-driven continue-update-rollback, and a config-only change is undone by re-uploading the previous YAML, not by rolling back the stack — both covered in Roll back to the previous version.

Teardown is partial by design

deploy:teardown leaves the config bucket, the artifacts bucket, and the deploy IAM user in place; deploy:submissions-teardown fails on a non-empty submissions bucket. Both are covered above — the manual steps exist so that tearing down a stack can never quietly destroy stored submissions or the credentials you are running with.

Tested surface

This flow has been exercised against a single AWS account in one region (us-east-1) with the default $default HTTP API stage. Nothing in it is region-specific — AWS_DEFAULT_REGION is threaded through every task — but other regions, and organizations with SCPs or permission boundaries restricting CloudFormation, IAM user creation, or ECR, have not been verified. If task relay:aws:bootstrap-user is blocked by policy, create the user by hand from deploy/deploy-policy.json in the vendored source and fill in its keys directly.