diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 208b859..16f9b6b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -119,6 +119,29 @@ litestream restore -config /etc/litestream.yml /data/landing/.state.sqlite source /opt/padelnomics/.env && bash infra/restore_landing.sh ``` +## Secrets management (SOPS + age) + +Secrets are stored encrypted in the repo using SOPS with age encryption: + +| File | Purpose | +|------|---------| +| `.env.dev.sops` | Dev defaults (safe/blank values) | +| `.env.prod.sops` | Production secrets | +| `.sops.yaml` | Maps file patterns to age public keys | + +```bash +# Decrypt dev secrets to .env (one-time, or after changes) +make secrets-decrypt-dev + +# Edit prod secrets (opens in $EDITOR, re-encrypts on save) +make secrets-edit-prod + +# deploy.sh auto-decrypts .env.prod.sops → .env on the server +``` + +All env vars are defined in the sops files. See `.env.dev.sops` for the full list +(decrypt with `make secrets-decrypt-dev` to read). + ## Environment variables | Variable | Default | Description | diff --git a/.env.dev.sops b/.env.dev.sops new file mode 100644 index 0000000..79fdc18 --- /dev/null +++ b/.env.dev.sops @@ -0,0 +1,71 @@ +#ENC[AES256_GCM,data:K5rgCg==,iv:NKfqoAGi2l5jeiFvf+QxoxC9ANhl7puNclWxFIESLCY=,tag:l3hlkF8O9D2muvNxMtDA0w==,type:comment] +APP_NAME=ENC[AES256_GCM,data:fl4hjYhHa/CVj+4=,iv:ECUJoqrMEamqYHlHUwMUxy+quWPChW08MhaFCWC09K8=,tag:BDn7Jc/48+Vz30YZW00Ztg==,type:str] +SECRET_KEY=ENC[AES256_GCM,data:2gCilpdrIohAPxfDhvfDmd60c1dc/ibUnn+k8+WsRRk=,iv:EJ7R8hugw3fZyuwglsWRWGGZ/PLBMsKfskq2n7k7zXk=,tag:6QJn4+bsRPhP80uaaQzfiw==,type:str] +BASE_URL=ENC[AES256_GCM,data:Kh37YIhKiLVwLnhLUrFeZSTz5IGu,iv:8yFioHoOqCyHuBMdEkRm9lZ+m+1/fSx8/ELqeIeK8hM=,tag:LMJGhz36tkJULuiqCRhtUg==,type:str] +DEBUG=ENC[AES256_GCM,data:TSzZcQ==,iv:sia85xQK2vl2Jd48yNsZ5yALdT3SKLsN5Z7w0SQbZEQ=,tag:H/HSISXQsEc5GPgfsfZASA==,type:str] +#ENC[AES256_GCM,data:RyfrEi05kPMVRYoBn7FdvMZGho29pliBcdiIFaJFGcrnv5DMwfpsa20ulQfKbId3i7P6giRjV76pe7l4/WUeB2OeDhFs/2BzXw==,iv:nKNjwGv7cvNNAy9AU42RsQaMrXPa+eANYpio0IK4f0A=,tag:GTswQ2L1xs/XqNu6QB5CmQ==,type:comment] +ADMIN_EMAILS=ENC[AES256_GCM,data:QOh2L6sLT60I6Dwp8A==,iv:2TU4Z/zMJlO3x9KfOK/8B2KCPfy3Tbj2VPyaPQLeFh0=,tag:SxS/jZ/eYDO2IIiHPShKRQ==,type:str] +#ENC[AES256_GCM,data:4eJgk539GHDF,iv:eZCLeDqY6UpqhfW7lApfV1XEhNONmkGK7e5DQM2uzkw=,tag:sUkkMcjePFWfjPt/1FV4tQ==,type:comment] +DATABASE_PATH=ENC[AES256_GCM,data:rFiaS9PJXyp+mIQ=,iv:+1KpHgt3V+xZQhR7Z2ShJkeihHu7AFw5VcTacdxvgoo=,tag:ZmpASVR6OY59xt4qZydmuw==,type:str] +#ENC[AES256_GCM,data:39BiQe4=,iv:ykThyqIcmCu4dEDFfNM9ZyChXYEkJk88ML7aCNMB3R8=,tag:i35mW7XLuQYFgBE4xfPU4g==,type:comment] +MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:HJU=,iv:ODdXG2LCG94W1bh4bUfBfSKjOgDSZsKPxc+sz3SLmZM=,tag:NFKp9xfimJRJY37iSV/+Xg==,type:str] +SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:D6c=,iv:N/KzyRWHEoDsMuItRvUQTKK7T8dT7dzN8PiKG9kyr50=,tag:9i5/gdgkTBnQpM2W0K9gAw==,type:str] +#ENC[AES256_GCM,data:Fui7Xiz1qFOTmdIWl+Hf,iv:X1dc0a6lnaBQai5pNsG0pnYRh6MHgnUpRJlsxdHIDHw=,tag:b1rSNH6vXC3r5n+VERi+fA==,type:comment] +#ENC[AES256_GCM,data:B7QdsSQPqbPSw6M1wK5UWZQo0dy3vC73TmOow4Uc93n561c0o9XFJWQCUeGowgJquvk1BTJf2U4ofMTn+mNhOVf7NDatgOiRFvKpEA==,iv:cAX9BHFSx6nacZf7ProiwndX7N36b43y3LypdjRrTpU=,tag:myq8rkCfqQfN+33gmN04BQ==,type:comment] +# +#ENC[AES256_GCM,data:+WON9GAd75jYQAeL0Q3lU2rbMY4mYVeweIYDZgAl0/A6smLmxFgzMg42yCwNbFdTzIWqW6KMuw3zdieCuBq8D/JoMFwt9iOVPJDKxWc41Nw=,iv:0FhM1VD+QWSBtFCmKJNJmU/L+SqZ6X7z1MSYgbqPhiM=,tag:TRa+PJl4RL35WToTb2qZ8Q==,type:comment] +#ENC[AES256_GCM,data:R6Bq7kozTTTCad7RAQR3X+ZewMiRFV+4qAA/oFc0X7Vj04gYUSVi9DxcsJ8/nuz8ujF/Bgyn1db/uvwBZmU0jLiyZYR8,iv:BDf7HEXqJE1bLNt5RbLeVBWEFAWCOo8Neqa789vGNcs=,tag:FkTS+16xnYk/FoYo208JsQ==,type:comment] +#ENC[AES256_GCM,data:InRMnsgUwdTnjTEG/svVnOW0ontn3Vbp7Z4qve4KIeVJbJW/aPIgoU1o22pu1ofua8WPyik=,iv:SEWBcbhEeA+cgHsfAQsglNdAZ4vyb3i6P6Ed+wpibhc=,tag:dkV2H8800vQsfrHYbhSCBA==,type:comment] +#ENC[AES256_GCM,data:MbSuH0ss3zblmktE79dyA1COw7vAaZVTBiCZ8qS006aqsIWT/3dikhCpWMjMx9vyoVIRSOmkIhM=,iv:jehJjOI4nqfGOSZtX4Bzvo7ZcaW1ef7OU+625i0xdzg=,tag:9NvEPJ+ioxo6Vg0yzLT6xw==,type:comment] +#ENC[AES256_GCM,data:C9bOMEvRHcFCjGUYZYiJIX/y2uTj9lQp4URf5ih3mpmOONqX7qfvzZGluU9QtSS76nB6kSBrEDcfqJ/E+4E=,iv:3afY88oeY5dyYDM7YZczXGMnaBygHgXqSjfnBAIkIhM=,tag:LpLqIx6YY9Dnmn4OgSrw9g==,type:comment] +#ENC[AES256_GCM,data:jdcE19OpkpM4UpgKRV3OxMD6PXnSTJmtxtRqalyM98gmm4iPgMaX6SmhDQwZWm4AZGPyAHtW9gE=,iv:8bcKPsBhuccPtPP8bgtsgtmPrOR8zdnXk1xt/umksQQ=,tag:sPYeQqZVf9JDqlDkoSSEbA==,type:comment] +#ENC[AES256_GCM,data:SCxHff2RuvkFwCaBGPRwS+7Z2LD8bIdXqWOvWNHyFDATfh55ph3SN/kCDqjWF1kSNuzxLUPNFV2iSigMUCjqHLLvPwZIWv8u,iv:zhvWvC5rrAITiKlNrms0mt2NEzqHdLN+x8ro391y4ac=,tag:X/K8qJqwgPWdHUpOG51d0A==,type:comment] +# +#ENC[AES256_GCM,data:7tNsVqa9hFBnXroNxQubpu91oYPaYcZLmfrdcyaThThJSUgTYqzZX9Vds5DXF/yqozREtZ/yhhzEtbmptM/MAM+6vzqem5l7RL0=,iv:KIehekko2c65QEEiPwyGXZE65OcjCgpWQzwwmIvIWhI=,tag:YQxZK2G0slxL+JB7peNkng==,type:comment] +RESEND_API_KEY= +EMAIL_FROM=ENC[AES256_GCM,data:TocTgLMrelrCf6pRgn5ilqfxmh0=,iv:RgMGVF3m2HcD85oN/RI1QoPFibq3IrNf+/+rJ/1Xpx8=,tag:PRPXOqYxHABlrcebcqJoBw==,type:str] +LEADS_EMAIL=ENC[AES256_GCM,data:SDQK7iW+qWRMzBuzXtHm1hOj/b0=,iv:DyzsiMER+pNbbaj+lq7eoNE3k7B+TZCZikgzHmA+bpY=,tag:2M63wNMzbHcAKAKXqwgBGQ==,type:str] +RESEND_WEBHOOK_SECRET= +#ENC[AES256_GCM,data:0QbG3ISB+wH4fCOWxfCsbkVuN+kIDmkTTOKhjiDRXauV1GI9cgCX/y0b47N74dzZ37b8nREKNIbY7nmAcK6e1HCVMA==,iv:qX2uHTLE4Z+q7y7bEqaAnCZ5SwJyG58vSqImF3sX+sA=,tag:CP7YuOnEruWIbaoK0IA+ag==,type:comment] +#ENC[AES256_GCM,data:X0jT2EwSEXu6raVEyXi7DFdk85M4tS25Z1JSTos2Jdr8TXDCqLQAG85FIJJql1ETxc6kbZLqbNCgXayMWhRGrGVMdmdtLoDLhFz5,iv:egi7pDNBiIj3lw5fDT98lLjvCzNt8yHxLcuV7VLd+LE=,tag:jASAHk7A8Pc1SC2tcvbj6g==,type:comment] +#ENC[AES256_GCM,data:hNquOLq2nymUmOrpQ4OAFqJlCt6SQaMWKHWsIlHmbjTuVt/m2KmY38kZOor+PqCCaPJszSS555Mb21cfhcKT8Ld2HVv9camDjsURXw==,iv:bkRK69bzm/ZFMBMp2P28/HMfjlgLknn0yhHbp+ane/o=,tag:N7NDRTED/c1pZSxhj4xRGQ==,type:comment] +#ENC[AES256_GCM,data:V/an6/nEqbI9HPk4sDdOjg8MsMoPT+dArWGWODdu1ZQV//GY0Qjx6x/oVwzyQP2RIYsQ,iv:Y4OLPcYbC945Z6ljyDYPrIyxBpLzT1NCEFZ7GbVooC4=,tag:FWYaiUbOm3Ef5xemN1R3vA==,type:comment] +PADDLE_API_KEY= +PADDLE_CLIENT_TOKEN= +PADDLE_WEBHOOK_SECRET= +PADDLE_NOTIFICATION_SETTING_ID= +PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:Jepf2hsljg==,iv:oDwkXtoHtK0nFxpua+PLTQAJKBX79cVcYKxuwEUiGo4=,tag:QWygdI+XLRgmqY4Il3THmA==,type:str] +#ENC[AES256_GCM,data:tMgeQQVTA/SWWRm+WMujmZUE2zl73m2eqihbsl4A+sKlFXz3LiDzrh65dhdup0dA/epsgKL0xKBRwpqu,iv:1c27FOmdfYhKPYL18CS8ehvE2CtWYIXQJFizhLA95CY=,tag:R0QHugw13zZpKpm8KoNU6A==,type:comment] +UMAMI_API_URL=ENC[AES256_GCM,data:qUiUHTJxYliRHclm0jaz1Ky6u5Oz3T6mYLafRg==,iv:dvDDYbA0TIuSWXxlDIF17jmHYOZ+HCtAjTkws3lPrPM=,tag:Ty3zsDLHG90AFweM+ywhEw==,type:str] +UMAMI_API_TOKEN= +#ENC[AES256_GCM,data:tvV1D8Twv3Ndm5jyTx4=,iv:cNjk1A0097KpqWkuMgr8bOscmeUTWV9msEJlXk4YdYI=,tag:TIQwazdxb3L+O6qqTc7ADw==,type:comment] +RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:o2g4,iv:DZknpelQp0FjJqUzBPHl5k3CmWz9TZqSZzAk8ww2cu4=,tag:A17LOfrpNyWw3H9AKsLDLg==,type:str] +RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:JT0=,iv:V8az/oyALVLZSOQ2IcjDnaBC7Mc5FyBh6ixYdjMEGf4=,tag:3SGD5jgH+vMYmnfPmck4Xg==,type:str] +#ENC[AES256_GCM,data:nnbUtBM3w/UM4p2S1p/CZNG3X3WxKdsCq+KiaoaphjveNMeukcVhvUzzwXHbFvcVjU5vqi/pZ/oJ3BmS2awEF/mj0IE1cwE7SCJyPl7yubg=,iv:fJ2hsz2DDpz07j6cNDdW+KEU/1nj5bkWNiDlHZLczYg=,tag:Bxig6DlNGb8ewiNijA0H8Q==,type:comment] +LITESTREAM_R2_BUCKET= +LITESTREAM_R2_ACCESS_KEY_ID= +LITESTREAM_R2_SECRET_ACCESS_KEY= +LITESTREAM_R2_ENDPOINT= +#ENC[AES256_GCM,data:9baHx5mEGWXYipBI0ic=,iv:CuatOQ25GP9ro/IurMaK1P/LDMlINuBMCHuBxn/NE3c=,tag:pCkfP3ESWXVDwTWNPOq2DQ==,type:comment] +DUCKDB_PATH=ENC[AES256_GCM,data:pp0WwSH1g4id4wW7kqksoA/Hkl9W,iv:Yp9ZhblwEVfMhV9f0kXlOiWqGSkUyF4kKjhBxH5qcpM=,tag:23TDdIEdF6Hr6WExcUPhuA==,type:str] +SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:SGQfIpaNdtsHvPjZMBfl68K8YAAX,iv:lwPXQFHse771413aOLx+Nd+iixQxOWjgbmPmvGhisZ4=,tag:ydBDnf0rsEoyJtYBk6j0Wg==,type:str] +LANDING_DIR=ENC[AES256_GCM,data:/a1P6vzDiWP8yak7,iv:jYxVP1Pl8yjgLF40+lkIh+HOZT7I0wXUZ8QfHMBUfDg=,tag:1YUFXDFp1Ofkea7cwvHAig==,type:str] +#ENC[AES256_GCM,data:Z8BKZ7RyncdHqKAX/DEPYSgbd2a9r6/KCaQ2whjuqXtx8ZFknEQ02z3/LqwD5016GDWS5AQ4ynwz/o+wkiYwqRVJz6L2020saCc=,iv:EOFGubnlTOfQ8in6sNulfyz8hl2hPR6mh8WWrJp1Vls=,tag:6n9C6VjJ2/jdQRXBtsGJRQ==,type:comment] +REPO_DIR=ENC[AES256_GCM,data:/A==,iv:9g4xelonlW6VD5/Q0Toyxfc8jaK9wf5XnGulrWqQimY=,tag:bcZa2tpAVOrriNzEqLW59Q==,type:str] +WORKFLOWS_PATH=ENC[AES256_GCM,data:VQHyJUsi27VybmdZ9lWdqhkX3cdnnbNsMtnDYx4F3w==,iv:msfHmNROnQnocM53PMm207JYvREAJ0OevtehrAy1oLU=,tag:Cqm+n9IdvZrc4hxzKA5RBg==,type:str] +ALERT_WEBHOOK_URL= +#ENC[AES256_GCM,data:JLh7PVzdpxGMLeU=,iv:tGji4+IqdpMlqc+CFHsO89TKUd0Dv3/KnSIuRPdsvic=,tag:VQ8cp0JpL5LpAMW4X1x7LA==,type:comment] +PROXY_URLS= +EXTRACT_WORKERS=ENC[AES256_GCM,data:YA==,iv:8ZHwE/D8gwgKSgPeOWq12Q9EEmu3BuoUehEapiC9HXc=,tag:rhrgYYJeFEc1ESjOWQmkFg==,type:str] +RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:e1o=,iv:LyUZbWBSokBrXqyT0rgCA+wUUysWuYW1HqKbWB1aq2U=,tag:SGRlUu9yyJ0xl1PC40kLyg==,type:str] +#ENC[AES256_GCM,data:qPKeMln983VWu/CriKBWsinPeCCmiv2japj0ztqBazSaIwnQDDe8Wcr5k4rK5dnyDf0ZavcomH8ERw62HiJ4Gv3bbP9ASdMx,iv:ri3t6qW3qanWNmMT8W8zTnU8sZQJkB/oEqcfvWa1Kz8=,tag:c1k6c6tfPnMvsrYnOaLbgA==,type:comment] +GSC_SERVICE_ACCOUNT_PATH= +GSC_SITE_URL= +BING_WEBMASTER_API_KEY= +BING_SITE_URL= +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRMDdkZElDbEw1OXJZOVI0\nU2ZtTlNaVXJOcThMNWVLakZiVmdBcHN6L2xBCnJBdHZxZS9BN0hxWXNHc3NxMHFL\nR0tmdHhZYmZkeEUwQ1dNczNGZ3hsenMKLS0tIEp1SWN2bGNkSzZ6WmV0cEdkRW0y\nbEdJODk1YXh0UjRNakhhT0ZqMmVFWDgKaDcLTJVLemHNAC3+ketRmqozNe8NJsp8\nx+ELsg4RgmQ0ZCkO0wGHeSgDx+RwIaPflvgJoyZuTA656oKnAeBF0A==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a +sops_lastmodified=2026-02-23T15:56:52Z +sops_mac=ENC[AES256_GCM,data:JIz/ueQy5Uz75btH9mHMMtCP1Yy1KDo3M/55B4iRhWO810Omg4fgswQpmsuYsFgygtc2qZX60o239zlea/fmO9xF7F/zwMebqFO4zuZR9UOTRIYiyskJzeFnl+Q0sT3bZNqErEU+kWGtc11D3VGRhNfAKTAVCmVoFLvUgw83WZY=,iv:NuBwfiafBeZ7ZorIQHgEb9zxs5uATiMoKaQ6QB7zpcA=,tag:izjqMBJQ2XVx+HPSRiGKew==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.12.1 diff --git a/.env.example b/.env.example deleted file mode 100644 index 3c11b7d..0000000 --- a/.env.example +++ /dev/null @@ -1,66 +0,0 @@ -# App -APP_NAME=Padelnomics -SECRET_KEY=change-me-generate-a-real-secret -BASE_URL=http://localhost:5000 -DEBUG=true - -# Admin access — comma-separated emails that get the admin role on login -ADMIN_EMAILS=dev@localhost - -# Database -DATABASE_PATH=data/app.db - -# Auth -MAGIC_LINK_EXPIRY_MINUTES=15 -SESSION_LIFETIME_DAYS=30 - -# Email (Resend) -# Leave blank for dev — emails print to console (no Resend account needed). -# -# Resend test addresses (work with any valid API key, no verified domain needed): -# delivered@resend.dev — accepted, simulates successful delivery -# bounced@resend.dev — simulates a hard bounce -# complained@resend.dev — simulates a spam complaint -# suppressed@resend.dev — simulates a suppressed recipient -# These support +label syntax: delivered+test1@resend.dev -# You can also send FROM onboarding@resend.dev without a verified domain. -# -# Dev login shortcut (no email needed): /auth/dev-login?email=dev@localhost -RESEND_API_KEY= -EMAIL_FROM=hello@padelnomics.io -LEADS_EMAIL=leads@padelnomics.io - -# Waitlist mode — set to true to gate routes and capture emails before launch. -# Resend audiences are created automatically per blueprint (waitlist-auth, -# waitlist-suppliers, etc.) on first signup — no audience IDs needed. -WAITLIST_MODE=false -# Optional: Resend audience ID for the planner/export waitlist (legacy, manual) -RESEND_AUDIENCE_PLANNER= - -# Paddle — leave blank to skip checkout (overlay won't initialize) -# Run `uv run python -m padelnomics.scripts.setup_paddle` to create products -# and a webhook notification destination. It writes PADDLE_WEBHOOK_SECRET and -# PADDLE_NOTIFICATION_SETTING_ID here automatically. -PADDLE_API_KEY= -PADDLE_CLIENT_TOKEN= -PADDLE_WEBHOOK_SECRET= -PADDLE_NOTIFICATION_SETTING_ID= -PADDLE_ENVIRONMENT=sandbox - -# Umami — leave blank for dev (analytics tracking disabled) -UMAMI_API_URL=https://umami.padelnomics.io -UMAMI_API_TOKEN= - -# Rate limiting -RATE_LIMIT_REQUESTS=100 -RATE_LIMIT_WINDOW=60 - -# Litestream R2 backup — leave blank to skip R2 replication (local-only backup) -LITESTREAM_R2_BUCKET= -LITESTREAM_R2_ACCESS_KEY_ID= -LITESTREAM_R2_SECRET_ACCESS_KEY= -LITESTREAM_R2_ENDPOINT= - -# DaaS analytics -DUCKDB_PATH=data/lakehouse.duckdb -LANDING_DIR=data/landing diff --git a/.env.prod.sops b/.env.prod.sops new file mode 100644 index 0000000..7222ca6 --- /dev/null +++ b/.env.prod.sops @@ -0,0 +1,58 @@ +#ENC[AES256_GCM,data:GOJ2Mw==,iv:67to0Qw7FeSkP//9ITuvmUetDmCnI6jvhluoxg53izg=,tag:jTuckO0mYqJP4UTOUFCY8w==,type:comment] +APP_NAME=ENC[AES256_GCM,data:/mL9kgdzFp7g9ck=,iv:QSNKZw/UUrl926V9jIz1r2nIM/LvmVi1ArvoLeHJvnY=,tag:pJiInY++jjjiZtnzR9+How==,type:str] +SECRET_KEY=ENC[AES256_GCM,data:G1LYiMWY/nlF,iv:ZM5xTG3kmwHZXC0oEQqv4LJWpkfLTeCX/88yCG9LB7E=,tag:Tji1wVd254/FO3zlYSKHMA==,type:str] +BASE_URL=ENC[AES256_GCM,data:pf6H6ykbdUpWd7bURmiWMGQNMj2M1g==,iv:QRLBGyCh6+ETedptOn7UvaR9CoNd5B+12GVOWGZ1630=,tag:n6OwsAfQfw8tLyyd7TUbUw==,type:str] +DEBUG=ENC[AES256_GCM,data:Q/4WRGs=,iv:tMQGcJtyMTxOSpTvaIUileajQmgbeHjJhQsIgy8/NK4=,tag:4EXkYxq8jpVNXK5HvMX2CQ==,type:str] +#ENC[AES256_GCM,data:LcZf6DLUULLHLqSDFQ==,iv:DpYEC/DyxUgiw0ildphTO1bLPPWS1AeCwiMIc+Bev4I=,tag:vzG1YLA8wYkELdqoF7gPaQ==,type:comment] +ADMIN_EMAILS=ENC[AES256_GCM,data:UBH9K/XyNy2s,iv:RnfTxscSs9+J8y1EHAbeqh9iMjxe/mdYNL8L7PVNBus=,tag:tujxgHXrdbF8RNMO0dQXVA==,type:str] +#ENC[AES256_GCM,data:oBkZCFhtbVUQ,iv:Elg6j/2+CK5C7+pJQtlZ6w3Hjq5SUhJgjtXvH5wI5FE=,tag:IYmdL+3n4ZDHbakIz/vh6w==,type:comment] +DATABASE_PATH=ENC[AES256_GCM,data:jjaWd6MPwLxLqEc=,iv:VQaYqAoonOBejkNPJao0PEq+mNDIKpp8d3HTlnh+uJU=,tag:Sd1DKsuA97SmqbWmCHTr8Q==,type:str] +#ENC[AES256_GCM,data:5wCu7HA=,iv:UfOWsur0H2k5lnTWmokovkT1s1OoBwhvtEF3WIpG8lE=,tag:zEZ4mNogvKRbOwF0AQhXFQ==,type:comment] +MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:9TM=,iv:Z9aeRhOxwxiCLVBwJty25yJb5NvSVKeQ+jaD8O5nygg=,tag:hjfsMzNaqxrtseTOZWUV2Q==,type:str] +SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:+qs=,iv:yyyxZXiGM/5MGqSPhIZsVgpK04vjNZ+TREIcHpR+KBc=,tag:CaxBq9o7I5Dj1k2sh7ymLA==,type:str] +#ENC[AES256_GCM,data:1Stv4fgcH5mVKO9WTObS,iv:52KswojsbF2erdfhqkmeWL+l1a9wvsCPxtmR51TX6Fk=,tag:vPiig+nRgf7j0JxZJw8sng==,type:comment] +RESEND_API_KEY=ENC[AES256_GCM,data:MowU2VDWwUe6,iv:0xQz/4QvOfvW6gS8WdZ6X5oF5fJhLue04weRm/kk1Xc=,tag:G8fXD2D+39v5boNKobXazg==,type:str] +EMAIL_FROM=ENC[AES256_GCM,data:LnNY3kTR3d4lPASQ0Y3JLimWT6ecnZui64ioqKbEV0OXLg==,iv:V/zHHeyVa5PFtwoLpfBA+pzuK/D+xR1/qmgErblND8w=,tag:uOwRHFpYZuQ7bHg9G6EnoA==,type:str] +LEADS_EMAIL=ENC[AES256_GCM,data:Gm0mRtLfLpqehG1CiJ609SXtOHAyUqC/eTj5FhNyGSBs/Q==,iv:OpCuF9qNuufZODtMUvFnLQ5PKdtyK8t1GK8z+3/f2K8=,tag:v0l1w9VfqcDej3uzHQ5uow==,type:str] +RESEND_WEBHOOK_SECRET=ENC[AES256_GCM,data:EBxScQDEB+it,iv:voliAf3I+tEwq7gwpRYOwexECfRDJlV0rq56IYWWgAw=,tag:Khja6sDENO5L5rjorwbafw==,type:str] +#ENC[AES256_GCM,data:NsVLPJkazw==,iv:A0AnmmoOVs8bJSA21lkTOHxOCJEYE5EvCT0BNnkdcjs=,tag:wsktHmXLDkkgikCw6byzRw==,type:comment] +PADDLE_API_KEY=ENC[AES256_GCM,data:NrK+Jj3yWBjA,iv:AERfauh9iNE0w+3/ypA9W7hFMXdHRksg4YIgSXuV3r0=,tag:yP/d7cqGgPZhfLpCLHIBGA==,type:str] +PADDLE_CLIENT_TOKEN=ENC[AES256_GCM,data:bRrmxWW9K+HQ,iv:T3bY4kCRUQAxESa5TVEscRF7RFsRmJoFcByd9z+25ZI=,tag:z/H8L2f8YCDblN3exTdw0g==,type:str] +PADDLE_WEBHOOK_SECRET=ENC[AES256_GCM,data:IQcNylVIYX0J,iv:/rVAMMhd4pQIg3QnHS9g9jQrZiVhGSV7R/7/UJ9NfQQ=,tag:2eoP73u5ZFPCNb5VeR2n9A==,type:str] +PADDLE_NOTIFICATION_SETTING_ID=ENC[AES256_GCM,data:vphzYBmfJs4J,iv:j4RM1C+BjLd0GgjnUwpIOY+xIH5hGN2r8tzyDdEvpIo=,tag:0eYFKiNu39ea+th4qWgUig==,type:str] +PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:nNwwW8yvBjjkww==,iv:1smhXkaPh2VrYMi27e+2YlB+g/5IapYrYhDgBDzBKgU=,tag:dONChSE4Kw4R3eGk5XMMhA==,type:str] +#ENC[AES256_GCM,data:fG/VIgDI,iv:ijPpFBL5QielOYTq6/vb5M1sxvRkb69zF1E5gacAG3k=,tag:eztxw1xzZIReRKWLLjFsug==,type:comment] +UMAMI_API_URL=ENC[AES256_GCM,data:5q8Eg57ISzm3vRzGnn+TJ3TemocQ8u4UqA6sSA==,iv:aFQmJ5yFMBbpglQB3UL+eYflJhG8BNoZH55VUqlB+q4=,tag:fbBGxg3Sns7ANL7I6Z8BSQ==,type:str] +UMAMI_API_TOKEN=ENC[AES256_GCM,data:4g4dX3u6vYUF,iv:nNtNivvqSaWZEWpW5622SVku9tYGEhcoJkv92sGyQ8Y=,tag:dcqLc35ApatBsZC7zLKALg==,type:str] +#ENC[AES256_GCM,data:1Bjmpc6T8BEFEWQ7KQE=,iv:S3m3Xjv/7E4esHxqCRKJ7dNo5ugj66QkqVofN5N3NA0=,tag:nxuTwReOlJKuQCNm3HTuSg==,type:comment] +RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:Wpuq,iv:2BL6WllqCX8zgVr+nlXV+kHachWaRaEassu90jbTsMM=,tag:eT4bWED9x0GVW+0+snn8qw==,type:str] +RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:+L4=,iv:rpUxUMzB5IIq+uZxdxXgI5yVd6Gb1elgHAh1g5rbBdA=,tag:JBMY8Ma8Sfpo6OwVRRPq+A==,type:str] +#ENC[AES256_GCM,data:8Zycx28XuJNg2blaBwTywuyfor7c,iv:ayuEkZySE32OUE1FxgM04FmJsiVFP6ixw8QD15WRtL0=,tag:9vNLZN2fllQ+nZYOhW1bNA==,type:comment] +LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:AS91kucfoMn3,iv:oMUi666wrWVEj6p6kpwS7D8kDlFnLPtWn10zAYtsxv4=,tag:zq0pP9ngdU3ncWXIzCLXlA==,type:str] +LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:wakzvPzR6GBT,iv:RBNO8zX13pWmPAksSW3jsLo08wsUU8ukPGcAoMFu+VM=,tag:7f5TPlIQB9i6zfGldri16A==,type:str] +LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:xXlTgbZEaZSW,iv:WCLblBc0AlZhs7rLNNOploRGSzaZCFSpCE9GpAmwmV0=,tag:P4zGJQQL0Ip2xM1JB8ID/g==,type:str] +LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:lP+KiIfTrLfR,iv:zHqvgc9q2vOyn0FKG2KQQEot4YbIM4thrq8feBN4yIM=,tag:LB/G025c747H1HfASrNJJw==,type:str] +#ENC[AES256_GCM,data:nHcA6qh78IoluGmCqDc=,iv:7De/oYcWG6AzE+htRR25zIP5NsHf+iGww6Qgu9N4Ofs=,tag:ft1CDUxRz7jdpL4zzyiX/A==,type:comment] +DUCKDB_PATH=ENC[AES256_GCM,data:wUeyLmV6pqSUZ3j0AKm//nFjyc0+,iv:w+bX5wxJAmFz/GJ9Y8r2nMkDbV4Ble8hVulVoz9HQGU=,tag:9isZWzAKZagowkY/tjg8rg==,type:str] +SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:D6plWMr/KvMk8/lCErfyIBsJoYFr,iv:DvNQrbkkrFXPRMeHXxiohc+xc4PoAdZUAiGPaate32k=,tag:K//K0t8G3SgqRWora7rCRA==,type:str] +LANDING_DIR=ENC[AES256_GCM,data:wUDIUODmP/0GTexh,iv:LP4Hu3jbDIk1tuMK3x/7E5zmzQ+UIuCNH8eSxMYygZI=,tag:WudSgvdjJChFfqDJAJrniA==,type:str] +#ENC[AES256_GCM,data:mFmS3KcXkFyfnVE=,iv:XKoJelbaUD+nsfdezeX4Ut3LckNdWdKGd4hPmcm7pa8=,tag:/HUpAQyyRWyCIexrbwiEWw==,type:comment] +REPO_DIR=ENC[AES256_GCM,data:wMoR17RdSag/7RT5boaUbQ==,iv:2+WBxWaX6/T+l0Jm7QYHkKGRbDbFWwi5/R4PRP/pcLg=,tag:n0VspEi93rPiduMb9/TStw==,type:str] +WORKFLOWS_PATH=ENC[AES256_GCM,data:REHdcRPOTQEI89hq8pni0c2J3q9hbZdFwnQ05B1mYw==,iv:WuiXwy/jY/+bFtP68+4bUzf71VoMbj2YxxrgABCpxq4=,tag:Elz4jQx0YUQNnllTvtaEXQ==,type:str] +ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:hLPoItVrm2a6,iv:kLfAEzxZPPbmF2usR+Gu7SlmlxXSJKLMRgZYkMT8YaQ=,tag:k6yzwoOVqMf5sMquBFTxxw==,type:str] +SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:vQ==,iv:1v+h99b+uWkt0lLYOsv/y8zYIVxugkY2tWQYho+Ovhg=,tag:JEkZpH5uyA4kUMdXrE45Qg==,type:str] +#ENC[AES256_GCM,data:28oxHMw4W/hGROs=,iv:r69QQXLePSQjy456Ef5WZlTF6Nn2Hr45ZP37WGrFzvg=,tag:f6c+9PjMvdRDI/4Zi8btug==,type:comment] +PROXY_URLS=ENC[AES256_GCM,data:nQ6WRn0c8Wek,iv:Bpe1QiK9wpVqDc1vf5tmYSjZNHZwYDsI2+8ORcGrEcM=,tag:U6y1zeBKGcNY2KHKMvM5sg==,type:str] +EXTRACT_WORKERS=ENC[AES256_GCM,data:Gg==,iv:WeksP3L6d1yvBSLl8t5ccO6YwkafAnE/CIrdBeFJggc=,tag:cELO1bkRdi2Fwv/MUQvlXQ==,type:str] +RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:O4M=,iv:+sNbrAqLN1LWmNlRUcqZ0E7PYZ2bQXOJBdqrELTtRoY=,tag:5UZ8jnnpXp/r+tHI9prunA==,type:str] +#ENC[AES256_GCM,data:ljUuAfDrd+58MVTfDOy/mh8=,iv:Hlsbiqzs7+YkVZmSTVEQPYLz/iDUXf3YWg+KzGE/U5M=,tag:dHIkizhjxaGEbYnv7wG3UA==,type:comment] +GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:QMo99bXHl2M4,iv:V2HY8QNy3jmBS7jmRWwLZymCKFAdFhkrg663J478G1A=,tag:3yC3fuecq6smATibbI97Fw==,type:str] +GSC_SITE_URL=ENC[AES256_GCM,data:MU3FVDO+jqE6lSBTPFUJgsqDBe/uWQ==,iv:hbwOMuY6O/DMKc+InYIzmmJ/cpbPD2HF0DQDViOYxxo=,tag:BhN2p0Eb9aZOJvhEjKbPYw==,type:str] +BING_WEBMASTER_API_KEY=ENC[AES256_GCM,data:ayHiaqHgyDxY,iv:YE57lcNHvegFhtYjv0CiOOoKdsz0cV9zGfEXWp7pxk0=,tag:o6puJ/KI+2k/eLBmZcUtsw==,type:str] +BING_SITE_URL=ENC[AES256_GCM,data:MVB7h2gLvyBs8d/snN1rwUHdicD88A==,iv:yN15Mv76fzMEVl7uPtSpzhykTrWDTLY3sYwLCIB5A3A=,tag:VK3Ul0DLNXA8ZDUDUE1Lcg==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjdm9VR2RVbkR0bXoxZUtk\ndVcrckp3MGtnK1dsUU5FcmVSK0QvZ3VYaURrCnNhUGRYck4rOFIrT3Q3YmFkTVpL\nSVcvZzFSQkRXcTA2ZUNXUDBTcC9jbzAKLS0tIFBSYnh6RVZZd3J2eUswMUp5cVJI\nM3JGNDlLR1lhL0VGZ0RXU25DcE9xQVUKoC/2M0/SpqVpSIhg3/dbYmAqskw9d3nm\ndvJ2TG5w0KZO0dIF9X1NynrCnh7raxNj8XQIlQOUVJUs8/Q6u1EjYQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a +sops_lastmodified=2026-02-23T16:03:19Z +sops_mac=ENC[AES256_GCM,data:1zBhmoOyu5mGD1W0TCOO9oFEYerXaD7Pm3NQs0o1cwytveDcn55hD0GR/z0PZTBf0taveqyuiuH9ugy3qdsX3PfDNKcadhgh2epoJeVq7imDnmWJ+wzURgQax72Xm39MBYRNZkyqYT+L7NuvdEF7wyfMsaVn9XcrMb+QdGC5Q+0=,iv:EaiNQNOXNX56FQwFqos7xVUVUAwEP0i0cVFpwaquKIY=,tag:4qaEv6le+KsLjEANcP9b/w==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.12.1 diff --git a/.gitignore b/.gitignore index 43ff7f2..24fcb96 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,8 @@ __pycache__/ venv/ .uv/ -# Environment +# Environment — .env is a decrypted artifact (gitignored) +# .env.*.sops files are encrypted and committed intentionally .env .env.local diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7598307..79e08f4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -29,32 +29,4 @@ deploy: - chmod 700 ~/.ssh - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts script: - - | - ssh "$DEPLOY_USER@$DEPLOY_HOST" "cat > /opt/padelnomics/.env" << ENVEOF - APP_NAME=$APP_NAME - SECRET_KEY=$SECRET_KEY - BASE_URL=$BASE_URL - DEBUG=false - ADMIN_PASSWORD=$ADMIN_PASSWORD - DATABASE_PATH=data/app.db - MAGIC_LINK_EXPIRY_MINUTES=${MAGIC_LINK_EXPIRY_MINUTES:-15} - SESSION_LIFETIME_DAYS=${SESSION_LIFETIME_DAYS:-30} - RESEND_API_KEY=$RESEND_API_KEY - EMAIL_FROM=${EMAIL_FROM:-hello@notifications.padelnomics.io} - ADMIN_EMAILS=${ADMIN_EMAILS:-} - LEADS_EMAIL=${LEADS_EMAIL:-} - UMAMI_API_URL=${UMAMI_API_URL:-} - WAITLIST_MODE=${WAITLIST_MODE:-false} - RATE_LIMIT_REQUESTS=${RATE_LIMIT_REQUESTS:-100} - RATE_LIMIT_WINDOW=${RATE_LIMIT_WINDOW:-60} - PADDLE_API_KEY=${PADDLE_API_KEY:-} - PADDLE_WEBHOOK_SECRET=${PADDLE_WEBHOOK_SECRET:-} - PADDLE_PRICE_STARTER=${PADDLE_PRICE_STARTER:-} - PADDLE_PRICE_PRO=${PADDLE_PRICE_PRO:-} - LITESTREAM_R2_BUCKET=$LITESTREAM_R2_BUCKET - LITESTREAM_R2_ACCESS_KEY_ID=$LITESTREAM_R2_ACCESS_KEY_ID - LITESTREAM_R2_SECRET_ACCESS_KEY=$LITESTREAM_R2_SECRET_ACCESS_KEY - LITESTREAM_R2_ENDPOINT=$LITESTREAM_R2_ENDPOINT - ENVEOF - - ssh "$DEPLOY_USER@$DEPLOY_HOST" "chmod 600 /opt/padelnomics/.env" - ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/padelnomics && git pull origin master && ./deploy.sh" diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..4bf3ffe --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,3 @@ +creation_rules: + - path_regex: \.env\..+\.sops$ + age: age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b334c..b27bbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- **SOPS + age encrypted secrets** — `.env.dev.sops` and `.env.prod.sops` replace + `.env.example` and GitLab CI/CD variables; age keypair for encryption/decryption; + `deploy.sh` auto-decrypts on server; `infra/setup_server.sh` installs sops + age + and generates server keypair; Makefile targets: `secrets-decrypt-dev`, + `secrets-decrypt-prod`, `secrets-edit-dev`, `secrets-edit-prod` + +### Removed +- `.env.example` — replaced by `.env.dev.sops` (decrypt with `make secrets-decrypt-dev`) +- GitLab CI heredoc that wrote `.env` via SSH — deploy.sh now handles decryption +- Dead `ADMIN_PASSWORD` CI variable reference +- Deprecated `WAITLIST_MODE` from env files (replaced by DB-backed feature flags) + - **Python supervisor** (`src/padelnomics/supervisor.py`) — replaces `supervisor.sh`; reads `infra/supervisor/workflows.toml` (module, schedule, entry, depends_on, proxy_mode); runs due workflows in topological waves (parallel within each wave); diff --git a/Makefile b/Makefile index 43edd45..477e6a2 100644 --- a/Makefile +++ b/Makefile @@ -10,3 +10,21 @@ css-build: bin/tailwindcss css-watch: bin/tailwindcss $(TAILWIND) -i web/src/padelnomics/static/css/input.css -o web/src/padelnomics/static/css/output.css --watch + +# -- Secrets (SOPS + age) -- +# .env.*.sops files use dotenv format but sops can't infer from the extension, +# so we pass --input-type / --output-type explicitly. + +SOPS_DOTENV := sops --input-type dotenv --output-type dotenv + +secrets-decrypt-dev: + $(SOPS_DOTENV) --decrypt .env.dev.sops > .env + +secrets-decrypt-prod: + $(SOPS_DOTENV) --decrypt .env.prod.sops > .env + +secrets-edit-dev: + $(SOPS_DOTENV) .env.dev.sops + +secrets-edit-prod: + $(SOPS_DOTENV) .env.prod.sops diff --git a/PROJECT.md b/PROJECT.md index 57ed4bb..7c401f5 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -13,7 +13,8 @@ - [x] UV workspace monorepo structure (web/, transform/, extract/ members) - [x] Docker + docker-compose production deploy - [x] Litestream R2 backup (1-year retention, auto-restore on startup) -- [x] CI pipeline (GitLab, env vars, health check gated deploys) +- [x] CI pipeline (GitLab, health check gated deploys) +- [x] SOPS + age encrypted secrets (`.env.dev.sops` / `.env.prod.sops`; `deploy.sh` auto-decrypts; `setup_server.sh` installs sops+age) - [x] Pre-migration DB backup + auto-restore on failed deploy - [x] Nginx router config diff --git a/deploy.sh b/deploy.sh index a82777f..74497c5 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +# ── Decrypt secrets ─────────────────────────────────────── +export SOPS_AGE_KEY_FILE="${SOPS_AGE_KEY_FILE:-/opt/padelnomics/age-key.txt}" +sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env +chmod 600 .env + COMPOSE="docker compose -f docker-compose.prod.yml" LIVE_FILE=".live-slot" ROUTER_CONF="router/default.conf" diff --git a/infra/setup_server.sh b/infra/setup_server.sh index 20417b7..8c7ed79 100644 --- a/infra/setup_server.sh +++ b/infra/setup_server.sh @@ -38,6 +38,46 @@ else echo "Deploy key already exists, skipping" fi +# Install sops + age (encrypted secrets) +AGE_KEY_FILE="$APP_DIR/age-key.txt" +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH_SOPS="amd64"; ARCH_AGE="amd64" ;; + aarch64) ARCH_SOPS="arm64"; ARCH_AGE="arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +if ! command -v age &>/dev/null; then + echo "Installing age..." + AGE_VERSION="v1.3.1" + curl -fsSL "https://dl.filippo.io/age/${AGE_VERSION}?for=linux/${ARCH_AGE}" -o /tmp/age.tar.gz + tar -xzf /tmp/age.tar.gz -C /usr/local/bin --strip-components=1 age/age age/age-keygen + chmod +x /usr/local/bin/age /usr/local/bin/age-keygen + rm /tmp/age.tar.gz + echo "Installed age $(age --version)" +else + echo "age already installed, skipping" +fi + +if ! command -v sops &>/dev/null; then + echo "Installing sops..." + SOPS_VERSION="v3.12.1" + curl -fsSL "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${ARCH_SOPS}" -o /usr/local/bin/sops + chmod +x /usr/local/bin/sops + echo "Installed sops $(sops --version)" +else + echo "sops already installed, skipping" +fi + +# Generate age keypair for this server (used by deploy.sh to decrypt secrets) +if [ ! -f "$AGE_KEY_FILE" ]; then + age-keygen -o "$AGE_KEY_FILE" 2>&1 + chmod 600 "$AGE_KEY_FILE" + echo "Generated age key: $AGE_KEY_FILE" +else + echo "Age key already exists: $AGE_KEY_FILE" +fi + # Install rclone (landing zone backup to R2) if ! command -v rclone &>/dev/null; then echo "Installing rclone..." @@ -70,8 +110,14 @@ echo "1. Add this deploy key to GitLab (Settings → Repository → Deploy Keys, echo "" cat "$KEY_PATH.pub" echo "" -echo "2. Clone the repo:" +echo "2. Add this server's age public key to .sops.yaml (comma-separated with existing keys):" +echo "" +grep "public key:" "$AGE_KEY_FILE" | awk '{print $NF}' +echo "" +echo " Then re-encrypt prod secrets: sops updatekeys .env.prod.sops" +echo "" +echo "3. Clone the repo:" echo " git clone git@gitlab.com:YOUR_USER/padelnomics.git $APP_DIR" echo "" -echo "3. Deploy:" +echo "4. Deploy:" echo " cd $APP_DIR && bash deploy.sh"