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
vinstalled- 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 | shSee 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 source —
Dockerfile,template.yaml,internal/,cmd/,deploy/,taskfiles/— atvendor/gitlab.com/frob/webform-relay/. This directory is overwritten wholesale on everyv 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, andwebform.yaml.v updatenever 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 --listFour 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 owndotenv:, which is whytaskfiles/deploy.ymlhas none — and it is what makes your project root the directory where.env,.env.project, andwebform.yamlare looked for.- No
vars:forwarding is needed.dotenvsets 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 itstaskfiles/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.ymlhas nodotenv:line (or lists different filenames). Task only reads dotenv from the top-level Taskfile, and silently ignores adotenv:inside an included one — so therelay:tasks run with an empty environment. Check withgrep dotenv Taskfile.yml. - You ran
taskfrom inside the vendored directory. That tree carries webform-relay’s own rootTaskfile.yml, which declares its own dotenv and includes the same deploy tasks under the samerelay:name — sotask relay:...works there, but resolves.envand.env.projectinside the vendored directory, where your config is not. Check withpwd.
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| File | Holds | Committed? |
|---|---|---|
.env | AWS access keys (deploy and bootstrap-admin) and FORM_TOKEN_SECRET | No — add it to your .gitignore |
.env.project | region, CONFIG_BUCKET, CONFIG_KEY, SAM_ARTIFACTS_BUCKET, STACK_NAME, CACHE_TTL, SES_FROM_ADDRESS, ALLOWED_ORIGINS, throttle limits, SUBMISSIONS_STACK_NAME | Yes |
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.
| Task | What it calls | When |
|---|---|---|
aws:bootstrap-user | iam:CreateUser, iam:CreatePolicy, iam:AttachUserPolicy, iam:CreateAccessKey | Once, at setup |
aws:update-deploy-policy | iam:GetUser, iam:GetPolicy, iam:CreatePolicyVersion (plus ListPolicyVersions/DeletePolicyVersion to stay under the five-version cap), iam:AttachUserPolicy | Whenever 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.jsonThen, 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>:
| Statement | Actions | Why |
|---|---|---|
DeployUser | iam:CreateUser, GetUser, TagUser/UntagUser, AttachUserPolicy, DetachUserPolicy, ListAttachedUserPolicies, PutUserPolicy, GetUserPolicy, ListUserPolicies, DeleteUserPolicy, DeleteUser | Create the deploy user and attach its policy |
DeployUserAccessKeys | iam:CreateAccessKey, ListAccessKeys, UpdateAccessKey, DeleteAccessKey | Issue its access key, and rotate or revoke it later |
DeployPolicy | iam:CreatePolicy, GetPolicy, CreatePolicyVersion, ListPolicyVersions, GetPolicyVersion, DeletePolicyVersion, TagPolicy/UntagPolicy, DeletePolicy | Create 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 bucketstask relay:aws:bootstrap runs three tasks:
relay:aws:bootstrap-user— creates awebform-relay-deployIAM user using theADMIN_*credentials in.env, attaches the least-privilege policy atdeploy/deploy-policy.jsonin 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 newAccessKeyId/SecretAccessKey— copy them intoAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYin.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 yourSTACK_NAMEas well as the default*webform-relay*. If you changeSTACK_NAMElater — or upgrade to a webform-relay whose policy needs a permission it didn’t before — refresh it:task relay:aws:update-deploy-policySkipping that shows up mid-deploy as
not authorized to perform: iam:CreateRole, which leaves the stack inROLLBACK_FAILED(see Recovering from a failed deploy). SetDEPLOY_USER_NAMEin.env.projectif your deploy user isn’t namedwebform-relay-deploy.relay:aws:bootstrap-bucket— creates the S3 bucket named byCONFIG_BUCKETin.env.project, using those deploy-user credentials.relay:aws:bootstrap-artifacts-bucket— creates a plain S3 bucket named bySAM_ARTIFACTS_BUCKETin.env.projectforsam deployto upload build artifacts into. This is deliberately not SAM’s own--resolve-s3auto-managed bucket: that flow provisions a shared CloudFormation stack namedaws-sam-cli-managed-defaultwith 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 inROLLBACK_FAILEDif any one permission is missing. A bucket we create ourselves with a plains3 mbsidesteps 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.yamlCONFIG= 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.project — STACK_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:appThis 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.
AllowedOriginsis a browser-read allowlist: it controls which origins may read the endpoint’s response from afetch/XHR call. It does not block the POST itself — acurlor 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 JSfetchsubmission; 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 barewww.example.com, a trailing slash, a path, or a space after a comma is rejected by API Gateway withInvalid format for origin.deploy:appchecks 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:
AllowHeadersalready 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).AllowMethodsalso permitsGETfor the htmx form/token endpoints, and preflight responses are cached for 5 minutes (MaxAge: 300) to cut repeat preflight chatter.
- Each entry must be a full origin, not a hostname: a scheme, a host, an
optional port, and nothing else —
ThrottleRateLimit/ThrottleBurstLimitcap 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 returns429 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-originAfter 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:appIf 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.yaml9. 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:appThat’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:appFor 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:appOn 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:appAWS_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:rollbackThis 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_FAILEDneedsaws cloudformation continue-update-rollback(the deploy user has the permission; there’s no task wrapper, since it usually needs--resources-to-skipchosen 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.bakRolling 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 againStep 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:teardownThis 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-deployTo 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 valueUse 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 lastThis 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-teardownNote 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> --recursiveStore 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.