Update deployment infrastructure

Torey Heinz committed Mar 14, 2026
commit d2ace26773dd022e58f4c4b44b497a65f332a790
Showing 11 changed files with 1173 additions and 0 deletions
.tool-versions +3 -0
@@ @@ -0,0 +1,3 @@
+ erlang 27.0
+ elixir 1.18.1-otp-27
+ nodejs 22.11.0
AGENTS.md +334 -0
@@ @@ -0,0 +1,334 @@
+ This is a web application written using the Phoenix web framework.
+
+ ## Project guidelines
+
+ - Use `mix precommit` alias when you are done with all changes and fix any pending issues
+ - Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
+
+ ### Phoenix v1.8 guidelines
+
+ - **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
+ - The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
+ - Anytime you run into errors with no `current_scope` assign:
+ - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
+ - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
+ - Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
+ - Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
+ - **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors
+ - If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
+ custom classes must fully style the input
+
+ ### JS and CSS guidelines
+
+ - **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
+ - Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
+
+ @import "tailwindcss" source(none);
+ @source "../css";
+ @source "../js";
+ @source "../../lib/my_app_web";
+
+ - **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
+ - **Never** use `@apply` when writing raw css
+ - **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
+ - Out of the box **only the app.js and app.css bundles are supported**
+ - You cannot reference an external vendor'd script `src` or link `href` in the layouts
+ - You must import the vendor deps into app.js and app.css to use them
+ - **Never write inline <script>custom js</script> tags within templates**
+
+ ### UI/UX & design guidelines
+
+ - **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
+ - Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
+ - Ensure **clean typography, spacing, and layout balance** for a refined, premium look
+ - Focus on **delightful details** like hover effects, loading states, and smooth page transitions
+
+
+ <!-- usage-rules-start -->
+
+ <!-- phoenix:elixir-start -->
+ ## Elixir guidelines
+
+ - Elixir lists **do not support index based access via the access syntax**
+
+ **Never do this (invalid)**:
+
+ i = 0
+ mylist = ["blue", "green"]
+ mylist[i]
+
+ Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
+
+ i = 0
+ mylist = ["blue", "green"]
+ Enum.at(mylist, i)
+
+ - Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
+ you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
+
+ # INVALID: we are rebinding inside the `if` and the result never gets assigned
+ if connected?(socket) do
+ socket = assign(socket, :val, val)
+ end
+
+ # VALID: we rebind the result of the `if` to a new variable
+ socket =
+ if connected?(socket) do
+ assign(socket, :val, val)
+ end
+
+ - **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
+ - **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
+ - Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
+ - Don't use `String.to_atom/1` on user input (memory leak risk)
+ - Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
+ - Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
+ - Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
+
+ ## Mix guidelines
+
+ - Read the docs and options before using tasks (by using `mix help task_name`)
+ - To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
+ - `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
+ <!-- phoenix:elixir-end -->
+
+ <!-- phoenix:phoenix-start -->
+ ## Phoenix guidelines
+
+ - Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
+
+ - You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
+
+ scope "/admin", AppWeb.Admin do
+ pipe_through :browser
+
+ live "/users", UserLive, :index
+ end
+
+ the UserLive route would point to the `AppWeb.Admin.UserLive` module
+
+ - `Phoenix.View` no longer is needed or included with Phoenix, don't use it
+ <!-- phoenix:phoenix-end -->
+
+ <!-- phoenix:ecto-start -->
+ ## Ecto Guidelines
+
+ - **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
+ - Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
+ - `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
+ - `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
+ - You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
+ - Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
+ <!-- phoenix:ecto-end -->
+
+ <!-- phoenix:html-start -->
+ ## Phoenix HTML guidelines
+
+ - Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
+ - **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
+ - When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
+ - **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
+ - For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
+
+ - Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
+
+ **Never do this (invalid)**:
+
+ <%= if condition do %>
+ ...
+ <% else if other_condition %>
+ ...
+ <% end %>
+
+ Instead **always** do this:
+
+ <%= cond do %>
+ <% condition -> %>
+ ...
+ <% condition2 -> %>
+ ...
+ <% true -> %>
+ ...
+ <% end %>
+
+ - HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
+
+ <code phx-no-curly-interpolation>
+ let obj = {key: "val"}
+ </code>
+
+ Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
+
+ - HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
+
+ <a class={[
+ "px-2 text-white",
+ @some_flag && "py-5",
+ if(@other_condition, do: "border-red-500", else: "border-blue-100"),
+ ...
+ ]}>Text</a>
+
+ and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
+
+ and **never** do this, since it's invalid (note the missing `[` and `]`):
+
+ <a class={
+ "px-2 text-white",
+ @some_flag && "py-5"
+ }> ...
+ => Raises compile syntax error on invalid HEEx attr syntax
+
+ - **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
+ - HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
+ - HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
+
+ **Always** do this:
+
+ <div id={@id}>
+ {@my_assign}
+ <%= if @some_block_condition do %>
+ {@another_assign}
+ <% end %>
+ </div>
+
+ and **Never** do this – the program will terminate with a syntax error:
+
+ <%!-- THIS IS INVALID NEVER EVER DO THIS --%>
+ <div id="<%= @invalid_interpolation %>">
+ {if @invalid_block_construct do}
+ {end}
+ </div>
+ <!-- phoenix:html-end -->
+
+ <!-- phoenix:liveview-start -->
+ ## Phoenix LiveView guidelines
+
+ - **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
+ - **Avoid LiveComponent's** unless you have a strong, specific need for them
+ - LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
+ - Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
+ - **Never** write embedded `<script>` tags in HEEx. Instead always write your scripts and hooks in the `assets/js` directory and integrate them with the `assets/js/app.js` file
+
+ ### LiveView streams
+
+ - **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
+ - basic append of N items - `stream(socket, :messages, [new_msg])`
+ - resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
+ - prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
+ - deleting items - `stream_delete(socket, :messages, msg)`
+
+ - When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
+
+ <div id="messages" phx-update="stream">
+ <div :for={{id, msg} <- @streams.messages} id={id}>
+ {msg.text}
+ </div>
+ </div>
+
+ - LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
+
+ def handle_event("filter", %{"filter" => filter}, socket) do
+ # re-fetch the messages based on the filter
+ messages = list_messages(filter)
+
+ {:noreply,
+ socket
+ |> assign(:messages_empty?, messages == [])
+ # reset the stream with the new messages
+ |> stream(:messages, messages, reset: true)}
+ end
+
+ - LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
+
+ <div id="tasks" phx-update="stream">
+ <div class="hidden only:block">No tasks yet</div>
+ <div :for={{id, task} <- @stream.tasks} id={id}>
+ {task.name}
+ </div>
+ </div>
+
+ The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
+
+ - **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
+
+ ### LiveView tests
+
+ - `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
+ - Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
+ - Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
+ - **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
+ - **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
+ - Instead of relying on testing text content, which can change, favor testing for the presence of key elements
+ - Focus on testing outcomes rather than implementation details
+ - Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
+ - When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
+
+ html = render(view)
+ document = LazyHTML.from_fragment(html)
+ matches = LazyHTML.filter(document, "your-complex-selector")
+ IO.inspect(matches, label: "Matches")
+
+ ### Form handling
+
+ #### Creating a form from params
+
+ If you want to create a form based on `handle_event` params:
+
+ def handle_event("submitted", params, socket) do
+ {:noreply, assign(socket, form: to_form(params))}
+ end
+
+ When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
+
+ You can also specify a name to nest the params:
+
+ def handle_event("submitted", %{"user" => user_params}, socket) do
+ {:noreply, assign(socket, form: to_form(user_params, as: :user))}
+ end
+
+ #### Creating a form from changesets
+
+ When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
+
+ defmodule MyApp.Users.User do
+ use Ecto.Schema
+ ...
+ end
+
+ And then you create a changeset that you pass to `to_form`:
+
+ %MyApp.Users.User{}
+ |> Ecto.Changeset.change()
+ |> to_form()
+
+ Once the form is submitted, the params will be available under `%{"user" => user_params}`.
+
+ In the template, the form form assign can be passed to the `<.form>` function component:
+
+ <.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
+ <.input field={@form[:field]} type="text" />
+ </.form>
+
+ Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
+
+ #### Avoiding form errors
+
+ **Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
+
+ <%!-- ALWAYS do this (valid) --%>
+ <.form for={@form} id="my-form">
+ <.input field={@form[:field]} type="text" />
+ </.form>
+
+ And **never** do this:
+
+ <%!-- NEVER do this (invalid) --%>
+ <.form for={@changeset} id="my-form">
+ <.input field={@changeset[:field]} type="text" />
+ </.form>
+
+ - You are FORBIDDEN from accessing the changeset in the template as it will cause errors
+ - **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
+ <!-- phoenix:liveview-end -->
+
+ <!-- usage-rules-end -->
\ No newline at end of file
build.sh +36 -0
@@ @@ -0,0 +1,36 @@
+ #!/usr/bin/env bash
+ # exit on error
+ set -o errexit
+
+ # Load ASDF (use explicit path for non-interactive shells)
+ export HOME="/home/dev"
+ source /home/dev/.asdf/asdf.sh
+ export PATH="/home/dev/.asdf/shims:/home/dev/.asdf/bin:$PATH"
+
+ # Debug: Check if mix is available
+ echo "PATH: $PATH"
+ which mix || { echo "mix not found in PATH"; exit 1; }
+
+ # Load environment (symlinked from shared/)
+ # Required for compile and migration steps in prod
+ if [ -f .env ]; then
+ echo "Loading environment from .env"
+ set -a
+ source .env
+ set +a
+ fi
+
+ # Initial setup
+ mix deps.get --only prod
+ MIX_ENV=prod mix compile
+
+ # Compile assets
+ MIX_ENV=prod mix assets.build
+ MIX_ENV=prod mix assets.deploy
+
+ # Run migrations
+ MIX_ENV=prod mix ecto.migrate
+
+ # Create server script, Build the release, and overwrite the existing release directory
+ MIX_ENV=prod mix phx.gen.release
+ MIX_ENV=prod mix release --overwrite
config/deploy.exs +23 -0
@@ @@ -0,0 +1,23 @@
+ import Config
+
+ # Deployment configuration for Mix.Tasks.Deploy
+ # See: lib/mix/tasks/deploy.ex
+
+ # Shared configuration across all environments
+ config :deploy,
+ repository: "git@github.com:toreyheinz/PiDayGame.git",
+ shared_dirs: ["tmp", "logs"],
+ shared_files: [".env"],
+ build_script: "./build.sh",
+ app_name: "pi_day"
+
+ # Production environment configuration
+ config :deploy, :production,
+ branch: "main",
+ user: "dev",
+ domain: "ssh.teagles.io",
+ port: 22,
+ deploy_to: "/var/www/piday.teagles.io",
+ url: "https://piday.teagles.io",
+ app_port: 4010
+
deploy/nginx.conf +60 -0
@@ @@ -0,0 +1,60 @@
+ # WebSocket connection upgrade
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ upstream ${UPSTREAM_NAME} {
+ server 127.0.0.1:${APP_PORT} fail_timeout=0;
+ }
+
+ server {
+ listen 80;
+ server_name ${SERVER_NAME};
+
+ # Logs
+ access_log /var/log/nginx/${SERVER_NAME}_access.log;
+ error_log /var/log/nginx/${SERVER_NAME}_error.log;
+
+ # All requests go through Phoenix (including static files)
+ location / {
+ proxy_pass http://${UPSTREAM_NAME};
+ proxy_http_version 1.1;
+
+ # Standard proxy headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Port $server_port;
+ proxy_set_header X-Forwarded-Host $host;
+
+ # WebSocket support
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+
+ # Timeouts
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 86400s; # Long timeout for WebSockets
+
+ # Buffer settings
+ proxy_buffering on;
+ proxy_buffer_size 4k;
+ proxy_buffers 8 4k;
+ proxy_busy_buffers_size 8k;
+
+ # File upload size
+ client_max_body_size 10M;
+ }
+
+ # Deny access to hidden files
+ location ~ /\. {
+ deny all;
+ }
+
+ # Let's Encrypt challenge location
+ location ~ /.well-known/acme-challenge {
+ allow all;
+ }
+ }
deploy/pi_day.service +31 -0
@@ @@ -0,0 +1,31 @@
+ [Unit]
+ Description=pi_day Phoenix Application
+ After=network.target postgresql.service
+
+ [Service]
+ Type=simple
+ User=dev
+ Group=dev
+ WorkingDirectory=${DEPLOY_TO}/current
+
+ # Environment
+ Environment="MIX_ENV=prod"
+ Environment="PORT=${APP_PORT}"
+ Environment="PHX_SERVER=true"
+ Environment="PHX_HOST=${PHX_HOST}"
+ Environment="LANG=en_US.UTF-8"
+
+ # Start/stop using release scripts (asdf sourced via rel/env.sh.eex)
+ ExecStart=${DEPLOY_TO}/current/_build/prod/rel/pi_day/bin/pi_day start
+ ExecStop=${DEPLOY_TO}/current/_build/prod/rel/pi_day/bin/pi_day stop
+
+ Restart=on-failure
+ RestartSec=5
+
+ # Logging
+ StandardOutput=journal
+ StandardError=journal
+ SyslogIdentifier=pi_day
+
+ [Install]
+ WantedBy=multi-user.target
mix/tasks/deploy.ex b/lib/mix/tasks/deploy.ex +287 -0
@@ @@ -0,0 +1,287 @@
+ defmodule Mix.Tasks.Deploy do
+ use Mix.Task
+
+ @shortdoc "Deploy Phoenix application to VPS"
+ @moduledoc """
+ Deploy Phoenix application to a VPS using SSH.
+
+ ## Usage
+
+ mix deploy # Deploy to production (default)
+ mix deploy staging # Deploy to staging
+ mix deploy staging feature-xyz # Deploy specific branch to staging
+
+ ## Configuration
+
+ Configure deployment in `config/deploy.exs`:
+
+ import Config
+
+ config :deploy,
+ repository: "git@github.com:username/app-name.git",
+ shared_dirs: ["uploads", "tmp", "logs"],
+ shared_files: [".env"],
+ build_script: "./build.sh"
+
+ config :deploy, :production,
+ branch: "main",
+ user: "dev",
+ domain: "ssh.example.com",
+ port: 22,
+ deploy_to: "/var/www/app-name",
+ url: "https://app.example.com",
+ app_port: 4000
+
+ config :deploy, :staging,
+ branch: "develop",
+ user: "dev",
+ domain: "ssh.example.com",
+ port: 22,
+ deploy_to: "/var/www/staging.app-name",
+ url: "https://staging.app.example.com",
+ app_port: 4001
+ """
+
+ require Logger
+
+ @impl Mix.Task
+ def run(args) do
+ # Load deployment config
+ Mix.Task.run("loadconfig", ["config/deploy.exs"])
+
+ {env, branch} = parse_args(args)
+ config = get_deploy_config(env)
+
+ # Allow branch override
+ config = if branch, do: Map.put(config, :branch, branch), else: config
+
+ Logger.info("Deploying to #{env} environment...")
+ Logger.info("Branch: #{config.branch}")
+ Logger.info("Host: #{config.user}@#{config.domain}:#{config.port}")
+ Logger.info("Deploy to: #{config.deploy_to}")
+
+ # Execute deployment
+ with :ok <- check_requirements(config),
+ {:ok, config} <- create_release_dir(config),
+ :ok <- clone_or_fetch_code(config),
+ :ok <- link_shared_paths(config),
+ :ok <- run_build_script(config),
+ :ok <- update_symlinks(config),
+ :ok <- restart_service(config),
+ :ok <- cleanup_old_releases(config),
+ :ok <- health_check(config) do
+ Logger.info("✅ Deployment completed successfully!")
+ else
+ {:error, reason} ->
+ Logger.error("❌ Deployment failed: #{reason}")
+ exit(1)
+ end
+ end
+
+ defp parse_args([]), do: {:production, nil}
+ defp parse_args([env]), do: {String.to_atom(env), nil}
+ defp parse_args([env, branch]), do: {String.to_atom(env), branch}
+
+ defp get_deploy_config(env), do: Deploy.SSH.get_deploy_config(env)
+
+ defp check_requirements(config) do
+ required_keys = [:repository, :user, :domain, :deploy_to, :branch]
+ missing = Enum.filter(required_keys, &(not Map.has_key?(config, &1)))
+
+ if Enum.empty?(missing) do
+ :ok
+ else
+ {:error, "Missing required config keys: #{inspect(missing)}"}
+ end
+ end
+
+ defp create_release_dir(config) do
+ timestamp = DateTime.utc_now() |> DateTime.to_unix() |> to_string()
+ release_path = "#{config.deploy_to}/releases/#{timestamp}"
+
+ case ssh_exec(config, "mkdir -p #{release_path}") do
+ {_, 0} ->
+ config = Map.put(config, :release_path, release_path)
+ {:ok, config}
+ {output, _} ->
+ {:error, "Failed to create release directory: #{output}"}
+ end
+ end
+
+ defp clone_or_fetch_code(%{release_path: release_path} = config) do
+ repo_path = "#{config.deploy_to}/repo"
+
+ # Clone or update repository
+ clone_cmd = """
+ if [ -d #{repo_path} ]; then
+ cd #{repo_path} && git fetch origin
+ else
+ git clone #{config.repository} #{repo_path}
+ fi
+ """
+
+ case ssh_exec(config, clone_cmd) do
+ {_, 0} ->
+ # Archive the specific branch to release directory (use origin/ to get fetched code)
+ archive_cmd = """
+ cd #{repo_path} && \
+ git archive origin/#{config.branch} | tar -x -C #{release_path}
+ """
+
+ case ssh_exec(config, archive_cmd) do
+ {_, 0} -> :ok
+ {output, _} -> {:error, "Failed to archive code: #{output}"}
+ end
+ {output, _} ->
+ {:error, "Failed to clone/fetch repository: #{output}"}
+ end
+ end
+
+ defp link_shared_paths(%{release_path: release_path} = config) do
+ shared_path = "#{config.deploy_to}/shared"
+
+ # Create shared directories if they don't exist
+ shared_dirs = Map.get(config, :shared_dirs, [])
+ shared_files = Map.get(config, :shared_files, [])
+
+ # Create shared structure
+ create_shared_cmd = """
+ mkdir -p #{shared_path} && \
+ #{Enum.map(shared_dirs, fn dir -> "mkdir -p #{shared_path}/#{dir}" end) |> Enum.join(" && ")}
+ """
+
+ case ssh_exec(config, create_shared_cmd) do
+ {_, 0} ->
+ # Link shared directories
+ link_dirs_cmd = shared_dirs
+ |> Enum.map(fn dir ->
+ "ln -nfs #{shared_path}/#{dir} #{release_path}/#{dir}"
+ end)
+ |> Enum.join(" && ")
+
+ # Link shared files
+ link_files_cmd = shared_files
+ |> Enum.map(fn file ->
+ dir = Path.dirname(file)
+ "mkdir -p #{release_path}/#{dir} && " <>
+ "if [ -f #{shared_path}/#{file} ]; then " <>
+ "ln -nfs #{shared_path}/#{file} #{release_path}/#{file}; " <>
+ "fi"
+ end)
+ |> Enum.join(" && ")
+
+ commands = [link_dirs_cmd, link_files_cmd]
+ |> Enum.reject(&(&1 == ""))
+
+ if Enum.empty?(commands) do
+ :ok
+ else
+ full_cmd = Enum.join(commands, " && ")
+ case ssh_exec(config, full_cmd) do
+ {_, 0} -> :ok
+ {output, _} -> {:error, "Failed to link shared paths: #{output}"}
+ end
+ end
+ {output, _} ->
+ {:error, "Failed to create shared directories: #{output}"}
+ end
+ end
+
+ defp run_build_script(%{release_path: release_path} = config) do
+ build_script = Map.get(config, :build_script, "./build.sh")
+
+ Logger.info("Running build script...")
+
+ # First make it executable
+ chmod_cmd = "chmod +x #{release_path}/#{build_script}"
+ case ssh_exec(config, chmod_cmd) do
+ {_, 0} ->
+ # Run the build script with explicit bash to ensure profile is loaded
+ build_cmd = "cd #{release_path} && /bin/bash -l -c '#{build_script}'"
+
+ case ssh_exec(config, build_cmd) do
+ {_, 0} -> :ok
+ {output, _} -> {:error, "Build failed: #{output}"}
+ end
+ {output, _} ->
+ {:error, "Failed to make build script executable: #{output}"}
+ end
+ end
+
+ defp update_symlinks(%{release_path: release_path} = config) do
+ current_path = "#{config.deploy_to}/current"
+
+ # Update current symlink atomically
+ update_cmd = """
+ ln -nfs #{release_path} #{current_path}.tmp && \
+ mv -Tf #{current_path}.tmp #{current_path}
+ """
+
+ case ssh_exec(config, update_cmd) do
+ {_, 0} -> :ok
+ {output, _} -> {:error, "Failed to update symlinks: #{output}"}
+ end
+ end
+
+ defp restart_service(config) do
+ # Service name is {app_name}-{env} (e.g., testapp-staging, testapp-production)
+ service_name = "#{config.app_name}-#{config.env}"
+
+ restart_cmd = """
+ sudo systemctl daemon-reload && \
+ sudo systemctl restart #{service_name} && \
+ sudo systemctl status #{service_name} | head -n 10
+ """
+
+ Logger.info("Restarting service: #{service_name}")
+
+ case ssh_exec(config, restart_cmd) do
+ {output, 0} ->
+ Logger.info(output)
+ :ok
+ {output, _} ->
+ {:error, "Failed to restart service: #{output}"}
+ end
+ end
+
+ defp cleanup_old_releases(config) do
+ # Keep only the last 5 releases
+ cleanup_cmd = """
+ cd #{config.deploy_to}/releases && \
+ ls -t | tail -n +6 | xargs -r rm -rf
+ """
+
+ case ssh_exec(config, cleanup_cmd) do
+ {_, 0} -> :ok
+ {_, _} ->
+ Logger.warning("Failed to cleanup old releases")
+ :ok # Don't fail deployment for cleanup
+ end
+ end
+
+ defp health_check(config) do
+ if url = Map.get(config, :url) do
+ Logger.info("Performing health check...")
+
+ health_cmd = "curl -fsSL -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 2 #{url}"
+
+ case ssh_exec(config, health_cmd) do
+ {"200", 0} ->
+ Logger.info("✅ Health check passed!")
+ :ok
+ {status, 0} ->
+ Logger.warning("Health check returned status: #{status}")
+ :ok # Don't fail for non-200 status
+ {output, _} ->
+ Logger.warning("Health check failed: #{output}")
+ :ok # Don't fail deployment for health check
+ end
+ else
+ :ok
+ end
+ end
+
+ defp ssh_exec(config, command, _opts \\ []), do: Deploy.SSH.ssh_exec(config, command)
+
+ defp scp_upload(config, local_file, remote_file), do: Deploy.SSH.scp_upload(config, local_file, remote_file)
+ end
\ No newline at end of file
mix/tasks/deploy.nginx.ex b/lib/mix/tasks/deploy.nginx.ex +156 -0
@@ @@ -0,0 +1,156 @@
+ defmodule Mix.Tasks.Deploy.Nginx do
+ use Mix.Task
+
+ @shortdoc "Update NGINX configuration on server"
+ @moduledoc """
+ Update NGINX configuration for the deployed Phoenix application.
+
+ ## Usage
+
+ mix deploy.nginx # Update nginx config for production
+ mix deploy.nginx staging # Update nginx config for staging
+
+ This task will:
+ 1. Upload the nginx configuration from deploy/nginx.conf
+ 2. Replace template variables with actual values
+ 3. Test the configuration
+ 4. Reload nginx if the test passes
+ """
+
+ require Logger
+
+ @impl Mix.Task
+ def run(args) do
+ # Load deployment config
+ Mix.Task.run("loadconfig", ["config/deploy.exs"])
+
+ env = case args do
+ [] -> :production
+ [env] -> String.to_atom(env)
+ _ ->
+ Logger.error("Too many arguments. Usage: mix deploy.nginx [environment]")
+ exit(1)
+ end
+
+ config = get_deploy_config(env)
+
+ Logger.info("Updating NGINX configuration for #{env} environment...")
+
+ with :ok <- check_requirements(config),
+ :ok <- upload_nginx_config(config),
+ :ok <- test_nginx_config(config),
+ :ok <- reload_nginx(config) do
+ Logger.info("✅ NGINX configuration updated successfully!")
+ else
+ {:error, reason} ->
+ Logger.error("❌ Failed to update NGINX configuration: #{reason}")
+ exit(1)
+ end
+ end
+
+ defp get_deploy_config(env), do: Deploy.SSH.get_deploy_config(env)
+
+ defp check_requirements(config) do
+ required_keys = [:user, :domain, :url, :app_port, :app_name]
+ missing = Enum.filter(required_keys, &(not Map.has_key?(config, &1)))
+
+ if Enum.empty?(missing) do
+ :ok
+ else
+ {:error, "Missing required config keys: #{inspect(missing)}"}
+ end
+ end
+
+ defp upload_nginx_config(config) do
+ # Check for nginx.conf in deploy directory
+ nginx_files = Path.wildcard("deploy/nginx*.conf")
+
+ nginx_file = cond do
+ "deploy/nginx.conf" in nginx_files -> "deploy/nginx.conf"
+ length(nginx_files) > 0 -> hd(nginx_files)
+ true -> nil
+ end
+
+ if nginx_file do
+ # Extract server name from URL - used as nginx config name for uniqueness
+ server_name = URI.parse(config.url).host || "localhost"
+
+ # SSL domain - could be different from server name for wildcard certs
+ ssl_domain = Map.get(config, :ssl_domain, server_name)
+
+ Logger.info("Uploading nginx configuration from #{nginx_file} for #{server_name}...")
+
+ # Read template
+ template = File.read!(nginx_file)
+
+ # Create unique upstream name from server_name (replace dots/dashes with underscores)
+ upstream_name = server_name |> String.replace(~r/[.-]/, "_")
+
+ # Replace variables
+ content = template
+ |> String.replace("${APP_NAME}", config.app_name)
+ |> String.replace("${APP_PORT}", to_string(config.app_port))
+ |> String.replace("${SERVER_NAME}", server_name)
+ |> String.replace("${SSL_DOMAIN}", ssl_domain)
+ |> String.replace("${UPSTREAM_NAME}", upstream_name)
+ |> String.replace("${DEPLOY_TO}", Map.get(config, :deploy_to, "/var/www/#{config.app_name}"))
+
+ # Create temp file - use server_name for unique filenames per environment
+ temp_file = Path.join(System.tmp_dir!(), "nginx-#{server_name}.conf")
+ File.write!(temp_file, content)
+
+ # Upload to server
+ remote_temp = "/tmp/nginx-#{server_name}.conf"
+
+ case scp_upload(config, temp_file, remote_temp) do
+ {_, 0} ->
+ # Move to sites-available - use server_name as config name
+ move_cmd = """
+ sudo cp #{remote_temp} /etc/nginx/sites-available/#{server_name} && \
+ sudo ln -sf /etc/nginx/sites-available/#{server_name} /etc/nginx/sites-enabled/#{server_name}
+ """
+
+ case ssh_exec(config, move_cmd) do
+ {_, 0} ->
+ File.rm(temp_file)
+ :ok
+ {output, _} ->
+ File.rm(temp_file)
+ {:error, "Failed to install nginx config: #{output}"}
+ end
+ {output, _} ->
+ File.rm(temp_file)
+ {:error, "Failed to upload nginx config: #{output}"}
+ end
+ else
+ {:error, "No nginx configuration file found in deploy/"}
+ end
+ end
+
+ defp test_nginx_config(config) do
+ Logger.info("Testing nginx configuration...")
+
+ case ssh_exec(config, "sudo nginx -t") do
+ {output, 0} ->
+ Logger.info(output)
+ :ok
+ {output, _} ->
+ {:error, "Nginx configuration test failed: #{output}"}
+ end
+ end
+
+ defp reload_nginx(config) do
+ Logger.info("Reloading nginx...")
+
+ case ssh_exec(config, "sudo systemctl reload nginx") do
+ {_, 0} ->
+ :ok
+ {output, _} ->
+ {:error, "Failed to reload nginx: #{output}"}
+ end
+ end
+
+ defp ssh_exec(config, command), do: Deploy.SSH.ssh_exec(config, command)
+
+ defp scp_upload(config, local_file, remote_file), do: Deploy.SSH.scp_upload(config, local_file, remote_file)
+ end
\ No newline at end of file
mix/tasks/deploy.service.ex b/lib/mix/tasks/deploy.service.ex +159 -0
@@ @@ -0,0 +1,159 @@
+ defmodule Mix.Tasks.Deploy.Service do
+ use Mix.Task
+
+ @shortdoc "Update systemd service on server"
+ @moduledoc """
+ Update systemd service configuration for the deployed Phoenix application.
+
+ ## Usage
+
+ mix deploy.service # Update service for production
+ mix deploy.service staging # Update service for staging
+
+ This task will:
+ 1. Upload the service file from deploy/
+ 2. Replace template variables with actual values
+ 3. Reload systemd daemon
+ 4. Restart the service
+ """
+
+ require Logger
+
+ @impl Mix.Task
+ def run(args) do
+ # Load deployment config
+ Mix.Task.run("loadconfig", ["config/deploy.exs"])
+
+ env = case args do
+ [] -> :production
+ [env] -> String.to_atom(env)
+ _ ->
+ Logger.error("Too many arguments. Usage: mix deploy.service [environment]")
+ exit(1)
+ end
+
+ config = get_deploy_config(env)
+
+ Logger.info("Updating systemd service for #{env} environment...")
+
+ with :ok <- check_requirements(config),
+ :ok <- upload_service_file(config),
+ :ok <- reload_and_restart_service(config) do
+ Logger.info("✅ Systemd service updated successfully!")
+ else
+ {:error, reason} ->
+ Logger.error("❌ Failed to update systemd service: #{reason}")
+ exit(1)
+ end
+ end
+
+ defp get_deploy_config(env), do: Deploy.SSH.get_deploy_config(env)
+
+ defp check_requirements(config) do
+ required_keys = [:user, :domain, :deploy_to, :app_port, :app_name]
+ missing = Enum.filter(required_keys, &(not Map.has_key?(config, &1)))
+
+ if Enum.empty?(missing) do
+ :ok
+ else
+ {:error, "Missing required config keys: #{inspect(missing)}"}
+ end
+ end
+
+ defp upload_service_file(config) do
+ # Look for service file
+ service_files = Path.wildcard("deploy/*.service")
+
+ service_file = case service_files do
+ [] -> nil
+ [file] -> file
+ files ->
+ # Prefer one that matches app name
+ Enum.find(files, hd(files), &String.contains?(&1, config.app_name))
+ end
+
+ if service_file do
+ # Service name is {app_name}-{env} (e.g., testapp-staging, testapp-production)
+ service_name = "#{config.app_name}-#{config.env}"
+
+ Logger.info("Uploading service file from #{service_file} as #{service_name}...")
+
+ # Read template
+ template = File.read!(service_file)
+
+ # Extract host from URL
+ host = case URI.parse(Map.get(config, :url, "")) do
+ %{host: nil} -> "localhost"
+ %{host: h} -> h
+ end
+
+ # Replace variables
+ content = template
+ |> String.replace("${DEPLOY_TO}", Map.get(config, :deploy_to, "/var/www/#{config.app_name}"))
+ |> String.replace("${APP_PORT}", to_string(config.app_port))
+ |> String.replace("${PHX_HOST}", host)
+
+ # Ensure PHX_SERVER=true is set
+ content = if not String.contains?(content, "PHX_SERVER") do
+ String.replace(content, "Environment=\"PORT=", "Environment=\"PHX_SERVER=true\"\nEnvironment=\"PORT=")
+ else
+ content
+ end
+
+ # Create temp file
+ temp_file = Path.join(System.tmp_dir!(), "#{service_name}.service")
+ File.write!(temp_file, content)
+
+ # Upload to server
+ remote_temp = "/tmp/#{service_name}.service"
+
+ case scp_upload(config, temp_file, remote_temp) do
+ {_, 0} ->
+ # Install service
+ install_cmd = """
+ sudo cp #{remote_temp} /etc/systemd/system/#{service_name}.service && \
+ sudo systemctl daemon-reload
+ """
+
+ case ssh_exec(config, install_cmd) do
+ {_, 0} ->
+ File.rm(temp_file)
+ :ok
+ {output, _} ->
+ File.rm(temp_file)
+ {:error, "Failed to install service: #{output}"}
+ end
+ {output, _} ->
+ File.rm(temp_file)
+ {:error, "Failed to upload service file: #{output}"}
+ end
+ else
+ {:error, "No service file found in deploy/"}
+ end
+ end
+
+ defp reload_and_restart_service(config) do
+ # Service name is {app_name}-{env} (e.g., testapp-staging, testapp-production)
+ service_name = "#{config.app_name}-#{config.env}"
+
+ Logger.info("Restarting service: #{service_name}")
+
+ restart_cmd = """
+ sudo systemctl daemon-reload && \
+ sudo systemctl restart #{service_name} && \
+ sudo systemctl status #{service_name} | head -n 10
+ """
+
+ case ssh_exec(config, restart_cmd) do
+ {output, 0} ->
+ Logger.info(output)
+ :ok
+ {output, _} ->
+ {:error, "Failed to restart service: #{output}"}
+ end
+ end
+
+ defp ssh_exec(config, command), do: Deploy.SSH.ssh_exec(config, command)
+
+ defp scp_upload(config, local_file, remote_file), do: Deploy.SSH.scp_upload(config, local_file, remote_file)
+ end
\ No newline at end of file
mix/tasks/deploy/ssh.ex b/lib/mix/tasks/deploy/ssh.ex +67 -0
@@ @@ -0,0 +1,67 @@
+ defmodule Deploy.SSH do
+ @moduledoc """
+ Shared SSH utilities for deployment mix tasks.
+
+ Provides ssh_exec/2, scp_upload/3, and get_deploy_config/1
+ used by Mix.Tasks.Deploy, Deploy.Service, and Deploy.Nginx.
+ """
+
+ require Logger
+
+ @doc """
+ Load and merge deployment config for the given environment.
+ Returns a map with all config keys merged from base + env.
+ """
+ def get_deploy_config(env) do
+ base_config = Application.get_all_env(:deploy)
+ env_config = Keyword.get(base_config, env, [])
+
+ if env_config == [] do
+ Logger.error("No configuration found for environment: #{env}")
+ exit(1)
+ end
+
+ app_name = Keyword.get(base_config, :app_name) || (Mix.Project.config()[:app] |> to_string())
+
+ base_config
+ |> Keyword.delete(:production)
+ |> Keyword.delete(:staging)
+ |> Keyword.merge(env_config)
+ |> Keyword.put(:env, env)
+ |> Keyword.put(:app_name, app_name)
+ |> Map.new()
+ end
+
+ @doc """
+ Execute a command on the remote server via SSH.
+ Returns {output, exit_code}.
+ """
+ def ssh_exec(config, command) do
+ port_opt = if config.port && config.port != 22, do: "-p #{config.port}", else: ""
+
+ ssh_command = """
+ ssh -T -A -o ConnectTimeout=10 #{port_opt} \
+ #{config.user}@#{config.domain} '#{command}'
+ """
+
+ Logger.debug("SSH command: #{inspect(ssh_command)}")
+ Logger.debug("Remote command: #{inspect(command)}")
+
+ System.cmd("bash", ["-c", ssh_command], stderr_to_stdout: true)
+ end
+
+ @doc """
+ Upload a local file to the remote server via SCP.
+ Returns {output, exit_code}.
+ """
+ def scp_upload(config, local_file, remote_file) do
+ port_opt = if config.port && config.port != 22, do: "-P #{config.port}", else: ""
+
+ scp_command = """
+ scp #{port_opt} #{local_file} \
+ #{config.user}@#{config.domain}:#{remote_file}
+ """
+
+ System.cmd("bash", ["-c", scp_command], stderr_to_stdout: true)
+ end
+ end
rel/env.sh.eex +17 -0
@@ @@ -0,0 +1,17 @@
+ #!/bin/sh
+
+ # Load environment file first (needed for unique node naming)
+ # The .env is symlinked from shared/ into the release directory
+ # RELEASE_ROOT is like /var/www/app/current/_build/prod/rel/app
+ # We need to go up to /var/www/app/current/.env
+ RELEASE_DIR="$(cd "${RELEASE_ROOT}/../../../.." 2>/dev/null && pwd)"
+ if [ -f "${RELEASE_DIR}/.env" ]; then
+ set -a
+ . "${RELEASE_DIR}/.env"
+ set +a
+ fi
+
+ # VPS deployment - use simple node naming (no distributed Erlang needed)
+ # Node name derived from PHX_HOST to ensure uniqueness per environment
+ export RELEASE_DISTRIBUTION="sname"
+ export RELEASE_NODE="pi_day_$(echo ${PHX_HOST:-localhost} | tr '.-' '_')"