Ansible + Terraform project for easy deployment of applications on AWS
This infrastructure is designed for fully automated deployment via CI/CD. Manual provisioning is discouraged.
Push to master. The GitHub Actions workflow will automatically:
- Provision AWS infrastructure via Terraform and configure SSH credentials (
ansible/playbooks/orchestrate.yml). - Validate the configuration by deploying against the CI runner itself as a smoke test.
- Deploy the validated configuration to the production server using the auto-generated inventory.
If you must run the deployment manually, replicate the pipeline steps in order:
# Step 1: Provision infrastructure and SSH setup
ansible-playbook \
-i ansible/inventory/local.ini \
--vault-password-file secrets/vault \
ansible/playbooks/orchestrate.yml
# Step 2: Validate configuration locally
ansible-playbook \
-i ansible/inventory/local.ini \
--vault-password-file secrets/vault \
ansible/playbooks/playbook.yml
# Step 3: Deploy to production
ansible-playbook \
-i ansible/inventory/production.ini \
--vault-password-file secrets/vault \
ansible/playbooks/playbook.ymlNote: Terraform automatically generates
ansible/inventory/production.iniduring the orchestration stage. Do not edit this file manually — changes will be overwritten on the next run.
Applications are strictly managed by Docker Compose. Do not use Ansible to deploy applications.
-
Define the container in
docker-compose.yml(repo root). -
Enforce local binding — the container's port must bind only to localhost, never directly to the internet:
ports: - "127.0.0.1:8080:80" # Correct # - "8080:80" # WRONG — exposes to the public internet
-
Configure the reverse proxy by adding the service to
ansible/inventory/group_vars/webservers.yml. Ansible generates the Nginx reverse-proxy config for you on the next run:# ansible/inventory/group_vars/webservers.yml services: - name: gitea port: 3000 - name: newapp # <-- add your new app here port: 8080
Simplest option. CI resolves the role from requirements.yml on every run.
-
Search first: find a well-maintained role on Ansible Galaxy (e.g.
geerlingguy.security). -
Add to requirements, pinned to an exact version:
# ansible/requirements.yml roles: - name: geerlingguy.redis version: "1.5.0"
-
Enable it in the
roles:list ofansible/playbooks/playbook.yml. -
Configure it via role variables in
ansible/inventory/group_vars/webservers.yml.
Never omit version: — an unpinned role resolves to whatever is newest at install time, which breaks the "a second run must be idempotent and reproducible" guarantee in section 3.
cd ansible/
ansible-galaxy install geerlingguy.firewall,2.7.0 -p ./roles/
git add roles/geerlingguy.firewall
git commit -m "vendor geerlingguy.firewall @ 2.7.0"Rules for vendored roles:
- Keep the license file. Only vendor MIT / Apache-2.0 / BSD roles (or anything else permissive) and leave the
LICENSEfile in place — that satisfies the attribution requirement. Don't vendor GPL or unlicensed roles without checking terms first. - Record provenance. Add a one-line note (in a
VENDORED.mdnext to the role, or a comment inrequirements.yml) with the source repo, version, and date pulled, so a future update is a deliberate, diffable act rather than a guess. - Bumping versions is a PR, not a background refresh. Re-run the install command,
git diffthe result, and review it like any other dependency bump before merging.
Vendored roles still go in ansible/roles/, alongside anything pulled via requirements.yml — there's no separate vendor/ directory in Ansible's model; a committed role directory is the vendored copy.
- Strict separation of concerns (Ansible vs. Docker). Ansible is for host-level configuration only: firewall, OS updates, user management, installing Docker/Nginx, and low-level network services like WireGuard. Docker Compose is for application services (Gitea, databases, web apps).
- No public container ports. Backend application ports must never be exposed directly to the internet. Containers bind strictly to
127.0.0.1and route through the Nginx reverse proxy. - Centralized firewall management. Do not write custom
iptablesorufwtasks inside other roles. To open a port for a non-HTTP service, append it tofirewall_allowed_tcp_ports/firewall_allowed_udp_portsinansible/inventory/group_vars/webservers.yml. - Use handlers for service restarts. Never call the
servicemodule directly to restart something intasks/main.yml. Usenotify: <Handler Name>and define the restart in the role'shandlers/main.yml. - No hardcoded variables. Never hardcode IPs, domains, or passwords in tasks or templates. Define them in
defaults/main.ymlor pass them via CI/CD environment variables. - Strict idempotency. Every run must be safely repeatable — a second run must produce
changed=0across all tasks. Use state-managing modules, orcreates/removes/changed_whenfor any raw shell commands. - Centralized configuration. All role variables and application settings belong in
ansible/inventory/group_vars/webservers.yml. Playbook-levelvars:are for computed, play-specific values only.
Automated deployments via GitHub Actions require AWS credentials and an Ansible Vault password. Every other secret (SSH keys, database passwords, etc.) lives inside the encrypted vault, not as a raw GitHub secret.
mkdir -p secrets
echo "YOUR_STRONG_VAULT_PASSWORD" > secrets/vaultThis file must never be committed.
The encrypted vault lives at ansible/inventory/group_vars/all/vault.yml and is committed to the repo in encrypted form.
Create a new vault from scratch:
ansible-vault create ansible/inventory/group_vars/all/vault.yml --vault-password-file secrets/vaultAdd secrets as plain YAML inside the editor that opens:
vault_aws_access_key_id: "AKIA..."
vault_aws_secret_access_key: "..."
vault_ssh_private_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
vault_gitea_db_password: "..."
vault_mysql_root_password: "..."Save and close — the file is encrypted on disk automatically.
Edit an existing vault:
ansible-vault edit ansible/inventory/group_vars/all/vault.yml --vault-password-file secrets/vaultView vault contents without editing:
ansible-vault view ansible/inventory/group_vars/all/vault.yml --vault-password-file secrets/vaultEncrypt a single string inline (for adding one secret without opening the full vault):
ansible-vault encrypt_string --vault-password-file secrets/vault 'my_secret_value' --name 'vault_my_variable'Copy the output directly into vault.yml.
secrets/ must be ignored — nothing inside it is ever committed:
echo "secrets/" >> .gitignore
git status # secrets/ should NOT appearOnly three secrets are needed in GitHub Actions. The committed, encrypted vault.yml is decrypted at runtime using VAULT_PASSWORD.
# AWS credentials
echo "$AWS_ACCESS_KEY_ID" | gh secret set AWS_ACCESS_KEY_ID
echo "$AWS_SECRET_ACCESS_KEY" | gh secret set AWS_SECRET_ACCESS_KEY
# Vault password (unlocks all other secrets at deploy time)
cat secrets/vault | gh secret set VAULT_PASSWORD
⚠️ Note: Use a dedicated SSH deploy key (e.g.id_ed25519_deploy) rather than your personal SSH key. Store its private key content insidevault.ymlasvault_ssh_private_key— not as a separate GitHub secret.